Skip to main content

headless_lms_models/library/agent_changesets/
publishing.rs

1//! Publishing a changeset to the live course.
2
3use std::borrow::Cow;
4use std::collections::{HashMap, HashSet};
5
6use futures::future::BoxFuture;
7use headless_lms_utils::document_schema_processor::walk;
8use url::Url;
9use utoipa::ToSchema;
10
11use crate::{
12    SpecFetcher, agent_changeset_calls,
13    agent_changeset_entities::{self, AgentChangesetEntity, ChangesetEntitySummary},
14    agent_changesets::{self, AgentChangeset, AgentChangesetStatus},
15    courses::{Course, TextEmbeddings},
16    entity_history::{self, HistoryAuthor},
17    error::missing_model_error,
18    exercise_service_info::ExerciseServiceInfoApi,
19    feedback,
20    library::{
21        agent_changesets::{
22            conflicts::{PublishConflict, conflicts_of},
23            course_state::{
24                CourseParts, PublishBlocker, copy_course_default_review_config, load_live_course,
25            },
26            locking::{ChangedSinceReview, LockRejection, lock_open_changeset},
27            risk::{ChangesetState, reclassify},
28        },
29        entity_snapshots::{
30            ChapterSnapshot, ContentEntity, ContentEntityType, CourseDefaultReviewConfigSnapshot,
31            CourseSettingsSnapshot, EntityKey, EntityRef, EntitySnapshot, GlossaryTermSnapshot,
32            ModuleSnapshot, PageSnapshot, ReferenceSnapshot, with_snapshot_type,
33        },
34    },
35    page_language_groups,
36    pages::{self, PageBlockReferences},
37    peer_or_self_review_configs::CmsPeerOrSelfReviewConfiguration,
38    prelude::*,
39    proposed_page_edits,
40    url_redirections::CreatedRedirect,
41};
42
43/// Why a publish was refused. Nothing was written.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
45#[serde(tag = "reason", rename_all = "snake_case")]
46pub enum PublishRejection {
47    /// Live entities changed since they were staged; their staged edits must be redone on the
48    /// current live state.
49    Conflicts { conflicts: Vec<PublishConflict> },
50    /// The changeset holds risky changes, which only the user can publish on the review page.
51    NeedsConfirmation {
52        risky_entities: Vec<ChangesetEntitySummary>,
53    },
54    /// Pages would add tasks of exercise services only global admins may add.
55    GlobalAdminOnlyExerciseServices { service_names: Vec<String> },
56    /// The course with the staged changes would break these rules publishing relies on, which
57    /// the live course doesn't.
58    Blockers { blockers: Vec<PublishBlocker> },
59    /// The staged changes changed after the revision the publish was based on.
60    ChangedSinceReview(ChangedSinceReview),
61    /// The changeset was published, discarded or expired before.
62    NotOpen { status: AgentChangesetStatus },
63    /// A save path refused what was staged for `entity`; `None` when the refusal came only once
64    /// every write was in. `message` is the save path's own, in English.
65    SaveFailed {
66        entity: Option<EntityRef>,
67        message: String,
68    },
69}
70
71impl From<LockRejection> for PublishRejection {
72    fn from(rejection: LockRejection) -> Self {
73        match rejection {
74            LockRejection::NotOpen(status) => Self::NotOpen { status },
75            LockRejection::ChangedSinceReview(changed) => Self::ChangedSinceReview(changed),
76        }
77    }
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
81#[serde(tag = "outcome", rename_all = "snake_case")]
82pub enum PublishOutcome {
83    Published {
84        changeset: AgentChangeset,
85        redirects: Vec<CreatedRedirect>,
86    },
87    Rejected {
88        rejection: PublishRejection,
89    },
90}
91
92/// How a publish may proceed. The publisher is always the changeset's owner, which the caller
93/// checks.
94#[derive(Debug, Clone, Copy)]
95pub struct PublishOptions {
96    /// Whether the publisher may add tasks of exercise services only global admins may add.
97    pub can_add_global_admin_only_exercise_services: bool,
98    /// The changeset revision the user reviewed on the review page; publishing any other revision
99    /// is rejected. Risky entities are published only with a reviewed revision.
100    pub reviewed_revision: Option<i32>,
101    /// The changeset revision an agent last saw; publishing any other revision is rejected. Unlike
102    /// `reviewed_revision`, it approves no risky entity.
103    pub seen_revision: Option<i32>,
104}
105
106/// The order publishing locks the live rows of entity kinds in. Every publish uses it, so two
107/// publishes never deadlock on each other.
108const LOCK_ORDER: [ContentEntityType; 7] = [
109    ContentEntityType::CourseSettings,
110    ContentEntityType::Module,
111    ContentEntityType::Chapter,
112    ContentEntityType::Page,
113    ContentEntityType::GlossaryTerm,
114    ContentEntityType::Reference,
115    ContentEntityType::CourseDefaultReviewConfig,
116];
117
118/// Locks the live rows of the staged entities until the transaction ends, so that nothing changes
119/// them between the conflict check and the writes.
120async fn lock_live_rows(
121    conn: &mut PgConnection,
122    entities: &[AgentChangesetEntity],
123) -> ModelResult<()> {
124    for entity_type in LOCK_ORDER {
125        let mut ids: Vec<Uuid> = entities
126            .iter()
127            .filter(|entity| entity.entity_type == entity_type)
128            .map(|entity| entity.entity_id)
129            .collect();
130        ids.sort();
131        with_snapshot_type!(entity_type, T => T::lock_rows(conn, &ids).await?);
132    }
133    Ok(())
134}
135
136/// Why publishing stops before writing anything.
137struct Refusal {
138    rejection: PublishRejection,
139    /// Whether the risk reasons classified on the way are stored all the same.
140    keeps_reclassification: bool,
141}
142
143/// Publishes an open changeset: writes every staged entity in one transaction, through the module
144/// and chapter writers, the CMS page save and the course save paths, and marks the changeset
145/// published.
146///
147/// Pages are saved with their exercise ids kept. Every history row written is attributed to the
148/// changeset's owner through the latest call that staged the entity. A page whose path changed
149/// gets a redirect from its old path.
150///
151/// Risk is classified again against the live course and student activity of this moment, and the
152/// new reasons are stored. Returns [`PublishOutcome::Rejected`], writing nothing, for any
153/// [`PublishRejection`] reason.
154pub async fn publish(
155    conn: &mut PgConnection,
156    changeset_id: Uuid,
157    options: PublishOptions,
158    app_conf: &ApplicationConfiguration,
159    spec_fetcher: impl SpecFetcher,
160    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
161) -> ModelResult<PublishOutcome> {
162    let embeddings = embed_staged_settings(conn, changeset_id, app_conf).await?;
163    let mut tx = conn.begin().await?;
164    let expected_revision = options.reviewed_revision.or(options.seen_revision);
165    let changeset = match lock_open_changeset(&mut tx, changeset_id, expected_revision).await? {
166        Ok(changeset) => changeset,
167        Err(rejection) => {
168            tx.rollback().await?;
169            return Ok(PublishOutcome::Rejected {
170                rejection: rejection.into(),
171            });
172        }
173    };
174    let course = crate::courses::get_course(&mut tx, changeset.course_id).await?;
175    let publishable = match check_publishable(&mut tx, &changeset, &course, options).await? {
176        Ok(publishable) => publishable,
177        Err(refusal) => {
178            if refusal.keeps_reclassification {
179                tx.commit().await?;
180            } else {
181                tx.rollback().await?;
182            }
183            return Ok(PublishOutcome::Rejected {
184                rejection: refusal.rejection,
185            });
186        }
187    };
188    let context = PublishContext {
189        changeset: &changeset,
190        course: &course,
191        app_conf,
192        embeddings: &embeddings,
193        spec_fetcher: &spec_fetcher,
194        fetch_service_info: &fetch_service_info,
195    };
196    let writes = StagedWrites::of(&publishable.entities, &publishable.bases)?;
197    let written = write_staged(&mut tx, &context, writes).await;
198    let redirects = match written {
199        Ok(redirects) => redirects,
200        Err(stop) => {
201            tx.rollback().await?;
202            return stop.into_outcome();
203        }
204    };
205    let changeset = agent_changesets::mark_published(&mut tx, changeset_id).await?;
206    // The uniqueness constraints `write_staged` defers are checked only now.
207    if let Err(stop) = saved(|| None, tx.commit().await.map_err(ModelError::from)) {
208        return stop.into_outcome();
209    }
210    Ok(PublishOutcome::Published {
211        changeset,
212        redirects,
213    })
214}
215
216/// Embeds the texts the changeset's staged course settings add or change; see [`TextEmbeddings`].
217async fn embed_staged_settings(
218    conn: &mut PgConnection,
219    changeset_id: Uuid,
220    app_conf: &ApplicationConfiguration,
221) -> ModelResult<TextEmbeddings> {
222    let keys: Vec<EntityKey> = agent_changesets::get_by_id(conn, changeset_id)
223        .await
224        .optional()?
225        .map(|changeset| EntityKey::new(ContentEntityType::CourseSettings, changeset.course_id))
226        .into_iter()
227        .collect();
228    let entities = agent_changeset_entities::get_by_keys(conn, changeset_id, &keys).await?;
229    let bases = agent_changeset_entities::get_bases(conn, changeset_id, &keys).await?;
230    let texts = written::<CourseSettingsSnapshot>(&entities, &bases)
231        .into_iter()
232        .flat_map(|(settings, base)| match base {
233            Some(base) => crate::courses::changed_settings_texts(settings, base),
234            None => crate::courses::settings_texts(settings),
235        })
236        .collect();
237    TextEmbeddings::create(app_conf, texts).await
238}
239
240/// A changeset that may be published: its staged rows and the bases they were staged on.
241struct Publishable {
242    entities: Vec<AgentChangesetEntity>,
243    bases: HashMap<EntityKey, EntitySnapshot>,
244}
245
246/// Locks what the changeset writes and checks that it may be published: no conflicts, no risk the
247/// publisher hasn't reviewed, no exercise services the publisher may not add. The risk reasons it
248/// classifies again are stored, not put in the returned rows.
249async fn check_publishable(
250    conn: &mut PgConnection,
251    changeset: &AgentChangeset,
252    course: &Course,
253    options: PublishOptions,
254) -> ModelResult<Result<Publishable, Refusal>> {
255    let entities = agent_changeset_entities::get_by_changeset_id(conn, changeset.id).await?;
256    // Writing pages fetches exercise specs from their services under these locks: which tasks
257    // need a new spec depends on the tasks the locked pages have.
258    lock_live_rows(conn, &entities).await?;
259    let keys: Vec<EntityKey> = entities.iter().map(AgentChangesetEntity::key).collect();
260    let bases = agent_changeset_entities::get_bases(conn, changeset.id, &keys).await?;
261
262    let conflicts = conflicts_of(conn, &entities, &bases).await?;
263    if !conflicts.is_empty() {
264        return Ok(Err(Refusal {
265            rejection: PublishRejection::Conflicts { conflicts },
266            keeps_reclassification: false,
267        }));
268    }
269
270    let live = load_live_course(conn, course, CourseParts::All).await?;
271    let state = ChangesetState::of_rows(Cow::Owned(live), &entities);
272    let blockers = state.staged.blockers_added_since(&state.live);
273    if !blockers.is_empty() {
274        return Ok(Err(Refusal {
275            rejection: PublishRejection::Blockers { blockers },
276            keeps_reclassification: false,
277        }));
278    }
279
280    let reclassified = reclassify(conn, changeset.id, &state).await?;
281    if reclassified.has_changed_reasons {
282        let revision = agent_changesets::increment_revision(conn, changeset.id).await?;
283        if options.reviewed_revision.is_some() && reclassified.has_new_reasons {
284            return Ok(Err(Refusal {
285                rejection: PublishRejection::ChangedSinceReview(ChangedSinceReview { revision }),
286                keeps_reclassification: true,
287            }));
288        }
289    }
290    if options.reviewed_revision.is_none() {
291        let risky_entities: Vec<ChangesetEntitySummary> = entities
292            .iter()
293            .filter_map(|entity| {
294                Some(ChangesetEntitySummary {
295                    risk_reasons: reclassified.reasons.get(&entity.key())?.clone(),
296                    ..ChangesetEntitySummary::from(entity)
297                })
298            })
299            .collect();
300        if !risky_entities.is_empty() {
301            return Ok(Err(Refusal {
302                rejection: PublishRejection::NeedsConfirmation { risky_entities },
303                keeps_reclassification: true,
304            }));
305        }
306    }
307
308    if !options.can_add_global_admin_only_exercise_services {
309        let tasks: Vec<_> = entities
310            .iter()
311            .filter_map(|entity| {
312                entity
313                    .after_snapshot
314                    .as_ref()
315                    .and_then(PageSnapshot::narrow)
316            })
317            .flat_map(|page| page.exercise_tasks.iter())
318            .cloned()
319            .collect();
320        let service_names = pages::get_global_admin_only_exercise_service_names_added_by_tasks(
321            conn,
322            pages::ExistingTasks::InCourse(changeset.course_id),
323            &tasks,
324        )
325        .await?;
326        if !service_names.is_empty() {
327            return Ok(Err(Refusal {
328                rejection: PublishRejection::GlobalAdminOnlyExerciseServices { service_names },
329                keeps_reclassification: false,
330            }));
331        }
332    }
333    Ok(Ok(Publishable { entities, bases }))
334}
335
336/// The staged entities of a changeset by what publishing does with them.
337struct StagedWrites<'a> {
338    modules: Vec<&'a ModuleSnapshot>,
339    chapters: Vec<&'a ChapterSnapshot>,
340    /// With the live page for updates.
341    pages: Vec<(&'a PageSnapshot, Option<&'a PageSnapshot>)>,
342    /// With the live settings.
343    course_settings: Vec<(&'a CourseSettingsSnapshot, &'a CourseSettingsSnapshot)>,
344    glossary_terms: Vec<&'a GlossaryTermSnapshot>,
345    references: Vec<&'a ReferenceSnapshot>,
346    course_default_review_configs: Vec<&'a CourseDefaultReviewConfigSnapshot>,
347    deleted: HashMap<ContentEntityType, Vec<&'a AgentChangesetEntity>>,
348}
349
350/// The staged snapshots of kind `T`, each with its base.
351fn written<'a, T: ContentEntity>(
352    entities: &'a [AgentChangesetEntity],
353    bases: &'a HashMap<EntityKey, EntitySnapshot>,
354) -> Vec<(&'a T, Option<&'a T>)> {
355    entities
356        .iter()
357        .filter_map(|entity| {
358            let after = T::narrow(entity.after_snapshot.as_ref()?)?;
359            Some((after, bases.get(&entity.key()).and_then(T::narrow)))
360        })
361        .collect()
362}
363
364fn afters<T>(written: Vec<(T, Option<T>)>) -> Vec<T> {
365    written.into_iter().map(|(after, _)| after).collect()
366}
367
368impl<'a> StagedWrites<'a> {
369    fn of(
370        entities: &'a [AgentChangesetEntity],
371        bases: &'a HashMap<EntityKey, EntitySnapshot>,
372    ) -> ModelResult<Self> {
373        let mut deleted: HashMap<ContentEntityType, Vec<&AgentChangesetEntity>> = HashMap::new();
374        for entity in entities.iter().filter(|e| e.after_snapshot.is_none()) {
375            deleted.entry(entity.entity_type).or_default().push(entity);
376        }
377        let course_settings = written::<CourseSettingsSnapshot>(entities, bases)
378            .into_iter()
379            .map(|(settings, live)| {
380                let live = live.ok_or_else(missing_model_error(
381                    ModelErrorType::Generic,
382                    "Course settings were staged without their live state",
383                ))?;
384                Ok((settings, live))
385            })
386            .collect::<ModelResult<_>>()?;
387        Ok(Self {
388            modules: afters(written(entities, bases)),
389            chapters: afters(written(entities, bases)),
390            pages: written(entities, bases),
391            course_settings,
392            glossary_terms: afters(written(entities, bases)),
393            references: afters(written(entities, bases)),
394            course_default_review_configs: afters(written(entities, bases)),
395            deleted,
396        })
397    }
398
399    fn deleted(&self, entity_type: ContentEntityType) -> &[&'a AgentChangesetEntity] {
400        self.deleted.get(&entity_type).map_or(&[], Vec::as_slice)
401    }
402}
403
404/// The staged exercises that switch from the course default review config to their own.
405fn exercises_switched_to_own_review_config(
406    pages: &[(&PageSnapshot, Option<&PageSnapshot>)],
407) -> HashSet<Uuid> {
408    let used_default: HashSet<Uuid> = pages
409        .iter()
410        .filter_map(|(_, base)| *base)
411        .flat_map(|base| base.exercises.iter())
412        .filter(|exercise| exercise.use_course_default_peer_or_self_review_config)
413        .map(|exercise| exercise.id)
414        .collect();
415    pages
416        .iter()
417        .flat_map(|(page, _)| page.exercises.iter())
418        .filter(|exercise| {
419            !exercise.use_course_default_peer_or_self_review_config
420                && used_default.contains(&exercise.id)
421        })
422        .map(|exercise| exercise.id)
423        .collect()
424}
425
426/// The staged page with the exercises in `switched` given a new copy of `course_default`, keeping
427/// the config id and review instructions staged: staging copied the default as it was then.
428fn with_course_default_copies<'a>(
429    page: &'a PageSnapshot,
430    switched: &HashSet<Uuid>,
431    course_default: Option<&CmsPeerOrSelfReviewConfiguration>,
432) -> Cow<'a, PageSnapshot> {
433    let Some(course_default) = course_default else {
434        return Cow::Borrowed(page);
435    };
436    if !page
437        .exercises
438        .iter()
439        .any(|exercise| switched.contains(&exercise.id))
440    {
441        return Cow::Borrowed(page);
442    }
443    let mut page = page.clone();
444    for exercise in page
445        .exercises
446        .iter_mut()
447        .filter(|exercise| switched.contains(&exercise.id))
448    {
449        if let Some(own) = exercise.peer_or_self_review_config.take() {
450            copy_course_default_review_config(
451                exercise,
452                course_default,
453                own.id,
454                own.review_instructions,
455                Uuid::new_v4,
456            );
457        }
458    }
459    Cow::Owned(page)
460}
461
462/// Who the history of each published entity is attributed to: the changeset's owner, through the
463/// latest call that staged the entity.
464struct Authors {
465    user_id: Uuid,
466    call_ids: HashMap<EntityKey, Uuid>,
467}
468
469impl Authors {
470    fn of(&self, key: EntityKey) -> ModelResult<HistoryAuthor> {
471        let call_id = self.call_ids.get(&key).ok_or_else(missing_model_error(
472            ModelErrorType::Generic,
473            format!("No call of the changeset staged {key:?}"),
474        ))?;
475        Ok(HistoryAuthor::agent(self.user_id, *call_id))
476    }
477
478    /// Records the published snapshot in the entity's history.
479    async fn record_saved<T: ContentEntity>(
480        &self,
481        conn: &mut PgConnection,
482        snapshot: &T,
483    ) -> ModelResult<()> {
484        let author = self.of(EntityKey::new(T::ENTITY_TYPE, snapshot.id()))?;
485        entity_history::record_saved(conn, &[snapshot.clone().into()], author).await
486    }
487}
488
489/// Why writing the staged entities stopped.
490enum WriteStop {
491    Refused(PublishRejection),
492    Failed(ModelError),
493}
494
495impl From<ModelError> for WriteStop {
496    fn from(error: ModelError) -> Self {
497        Self::Failed(error)
498    }
499}
500
501impl WriteStop {
502    fn into_outcome(self) -> ModelResult<PublishOutcome> {
503        match self {
504            Self::Refused(rejection) => Ok(PublishOutcome::Rejected { rejection }),
505            Self::Failed(error) => Err(error),
506        }
507    }
508}
509
510/// `result` of saving `entity`, with a save path's refusal of what was staged as
511/// [`PublishRejection::SaveFailed`].
512fn saved<T>(
513    entity: impl FnOnce() -> Option<EntityRef>,
514    result: ModelResult<T>,
515) -> Result<T, WriteStop> {
516    result.map_err(|error| match error.error_type() {
517        ModelErrorType::PreconditionFailed
518        | ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { .. }
519        | ModelErrorType::InvalidRequest
520        | ModelErrorType::DatabaseConstraint { .. } => {
521            WriteStop::Refused(PublishRejection::SaveFailed {
522                entity: entity(),
523                message: error.message().to_string(),
524            })
525        }
526        _ => WriteStop::Failed(error),
527    })
528}
529
530fn entity_ref<T: ContentEntity>(snapshot: &T) -> Option<EntityRef> {
531    Some(EntityRef {
532        entity_type: T::ENTITY_TYPE,
533        entity_id: snapshot.id(),
534        label: snapshot.label().to_string(),
535    })
536}
537
538/// What writing a changeset's staged entities uses besides the entities.
539struct PublishContext<'a, S, F> {
540    changeset: &'a AgentChangeset,
541    course: &'a Course,
542    app_conf: &'a ApplicationConfiguration,
543    /// Made before the publish transaction; see [`TextEmbeddings`].
544    embeddings: &'a TextEmbeddings,
545    spec_fetcher: &'a S,
546    fetch_service_info: &'a F,
547}
548
549/// Writes the staged entities in an order where nothing points at what doesn't exist yet, records
550/// their history and returns the redirects created from changed page paths.
551async fn write_staged<S, F>(
552    conn: &mut PgConnection,
553    context: &PublishContext<'_, S, F>,
554    writes: StagedWrites<'_>,
555) -> Result<Vec<CreatedRedirect>, WriteStop>
556where
557    S: SpecFetcher,
558    F: Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
559{
560    let PublishContext {
561        changeset,
562        course,
563        app_conf,
564        embeddings,
565        spec_fetcher,
566        fetch_service_info,
567    } = *context;
568    let authors = Authors {
569        user_id: changeset.user_id,
570        call_ids: agent_changeset_calls::get_latest_by_entity(conn, changeset.id).await?,
571    };
572    // Staged entities may swap numbers, paths and citation keys, which are unique only once all
573    // are written and deleted, and new chapters point at front pages written after them.
574    crate::courses::defer_structure_constraints(conn).await?;
575
576    for module in &writes.modules {
577        let author = authors.of(EntityKey::new(ContentEntityType::Module, module.id))?;
578        saved(
579            || entity_ref(*module),
580            crate::course_modules::upsert_from_snapshot(conn, module, author.user_id).await,
581        )?;
582        authors.record_saved(conn, *module).await?;
583    }
584
585    for chapter in &writes.chapters {
586        saved(
587            || entity_ref(*chapter),
588            crate::chapters::upsert_from_snapshot(conn, chapter).await,
589        )?;
590        authors.record_saved(conn, *chapter).await?;
591    }
592
593    let switched = exercises_switched_to_own_review_config(&writes.pages);
594    let course_default = if switched.is_empty() {
595        None
596    } else {
597        crate::peer_or_self_review_configs::get_course_default_cms_peer_review_configuration(
598            conn, course.id,
599        )
600        .await?
601    };
602    let mut redirects = Vec::new();
603    for (page, live) in &writes.pages {
604        let page = &with_course_default_copies(page, &switched, course_default.as_ref());
605        let saved_page = pages::upsert_from_snapshot(
606            conn,
607            course,
608            page,
609            live.is_none(),
610            authors.of(EntityKey::new(ContentEntityType::Page, page.id))?,
611            spec_fetcher,
612            fetch_service_info,
613        )
614        .await;
615        redirects.extend(saved(|| entity_ref(&**page), saved_page)?);
616    }
617
618    for (settings, live) in &writes.course_settings {
619        // Agents can't edit can_add_chatbot, and a live change to it is a conflict.
620        saved(
621            || entity_ref(*settings),
622            crate::courses::write_settings(conn, app_conf, settings, live, false, embeddings).await,
623        )?;
624        authors.record_saved(conn, *settings).await?;
625    }
626    for term in &writes.glossary_terms {
627        saved(
628            || entity_ref(*term),
629            crate::glossary::upsert_from_snapshot(conn, term).await,
630        )?;
631    }
632    for reference in &writes.references {
633        saved(
634            || entity_ref(*reference),
635            crate::material_references::upsert_from_snapshot(conn, reference).await,
636        )?;
637    }
638
639    for config in &writes.course_default_review_configs {
640        saved(
641            || entity_ref(*config),
642            crate::peer_or_self_review_configs::set_course_default_review_instructions(
643                conn,
644                config.id,
645                config.course_id,
646                config.review_instructions.as_ref(),
647            )
648            .await,
649        )?;
650        authors.record_saved(conn, *config).await?;
651    }
652
653    move_block_references(conn, changeset.course_id, &writes).await?;
654    delete_removed(conn, &writes, &authors).await?;
655    // Pairing is checked when staged, but a discard, revert or another editor's publish can give
656    // the course its version of the page since.
657    for (page, _) in writes.pages.iter().filter(|(_, live)| live.is_none()) {
658        let Some(group) = page.joins_page_language_group_id else {
659            continue;
660        };
661        if page_language_groups::course_has_other_page(conn, group, course.id, page.id).await? {
662            return Err(WriteStop::Refused(PublishRejection::SaveFailed {
663                entity: entity_ref(*page),
664                message:
665                    "This course already has its version of the page this page was paired with"
666                        .to_string(),
667            }));
668        }
669    }
670    Ok(redirects)
671}
672
673/// Points feedback and edit proposals at the page their blocks moved to.
674///
675/// A record moves only when none of its blocks is left on its page; it then follows its first
676/// block that still exists. Blocks keep their `clientId` when moved, so only pages in the
677/// changeset can have gained or lost them.
678async fn move_block_references(
679    conn: &mut PgConnection,
680    course_id: Uuid,
681    writes: &StagedWrites<'_>,
682) -> ModelResult<()> {
683    let mut changed_page_ids: Vec<Uuid> = writes
684        .deleted(ContentEntityType::Page)
685        .iter()
686        .map(|page| page.entity_id)
687        .collect();
688    let mut page_of_block = HashMap::new();
689    for (page, live) in &writes.pages {
690        if live.is_some() {
691            changed_page_ids.push(page.id);
692        }
693        walk(&page.content, &mut |block, _| {
694            page_of_block.insert(block.client_id, page.id);
695        });
696    }
697    if changed_page_ids.is_empty() {
698        return Ok(());
699    }
700    let moves = |records: &[PageBlockReferences]| {
701        let mut ids = Vec::new();
702        let mut page_ids = Vec::new();
703        for record in records {
704            let mut pages = record
705                .block_ids
706                .iter()
707                .filter_map(|id| page_of_block.get(id).copied());
708            if pages.clone().any(|page_id| page_id == record.page_id) {
709                continue;
710            }
711            if let Some(page_id) = pages.next() {
712                ids.push(record.id);
713                page_ids.push(page_id);
714            }
715        }
716        (ids, page_ids)
717    };
718
719    let (ids, page_ids) =
720        moves(&feedback::get_block_ids_by_page_ids(conn, course_id, &changed_page_ids).await?);
721    feedback::set_page_ids(conn, &ids, &page_ids).await?;
722    let (ids, page_ids) = moves(
723        &proposed_page_edits::get_block_ids_by_page_ids(conn, course_id, &changed_page_ids).await?,
724    );
725    proposed_page_edits::set_page_ids(conn, &ids, &page_ids).await?;
726    Ok(())
727}
728
729/// Deletes the entities the changeset removes: chapters with their pages first, then the other
730/// pages, then modules, so that nothing still points at what is deleted next.
731async fn delete_removed(
732    conn: &mut PgConnection,
733    writes: &StagedWrites<'_>,
734    authors: &Authors,
735) -> Result<(), WriteStop> {
736    for chapter in writes.deleted(ContentEntityType::Chapter) {
737        let author = authors.of(chapter.key())?;
738        saved(
739            || Some(chapter.entity_ref()),
740            crate::chapters::delete_chapter(conn, chapter.entity_id, author).await,
741        )?;
742    }
743    for page in writes.deleted(ContentEntityType::Page) {
744        // The pages of deleted chapters are gone already.
745        if pages::exists(conn, page.entity_id).await? {
746            let author = authors.of(page.key())?;
747            saved(
748                || Some(page.entity_ref()),
749                pages::delete_page_and_exercises(conn, page.entity_id, author).await,
750            )?;
751        }
752    }
753    for module in writes.deleted(ContentEntityType::Module) {
754        entity_history::record_deleted(
755            conn,
756            module.entity_type,
757            module.entity_id,
758            authors.of(module.key())?,
759        )
760        .await?;
761        saved(
762            || Some(module.entity_ref()),
763            crate::course_modules::delete(conn, module.entity_id).await,
764        )?;
765    }
766    for term in writes.deleted(ContentEntityType::GlossaryTerm) {
767        saved(
768            || Some(term.entity_ref()),
769            crate::glossary::delete(conn, term.entity_id).await,
770        )?;
771    }
772    for reference in writes.deleted(ContentEntityType::Reference) {
773        saved(
774            || Some(reference.entity_ref()),
775            crate::material_references::delete_reference(conn, reference.entity_id).await,
776        )?;
777    }
778    Ok(())
779}