Skip to main content

headless_lms_models/library/agent_changesets/
locking.rs

1//! Locking an open changeset for a publish or discard, and discarding it.
2
3use utoipa::ToSchema;
4
5use crate::{
6    agent_changeset_entities::{self, AgentChangesetEntity},
7    agent_changesets::{self, AgentChangeset, AgentChangesetStatus},
8    library::{
9        agent_changesets::{
10            course_state::PublishBlocker,
11            risk::{RiskBaseline, reclassify},
12        },
13        entity_snapshots::{EntityKey, EntityRef},
14    },
15    prelude::*,
16};
17
18/// The staged changes changed after the revision the user based a publish or discard on. Nothing
19/// was written.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
21pub struct ChangedSinceReview {
22    /// The changeset's revision now.
23    pub revision: i32,
24}
25
26/// Why a publish or discard couldn't act on the changeset.
27pub(super) enum LockRejection {
28    NotOpen(AgentChangesetStatus),
29    ChangedSinceReview(ChangedSinceReview),
30}
31
32/// Locks the changeset until the transaction ends and checks that it is open and, with
33/// `reviewed_revision`, still at that revision.
34pub(super) async fn lock_open_changeset(
35    conn: &mut PgConnection,
36    changeset_id: Uuid,
37    reviewed_revision: Option<i32>,
38) -> ModelResult<Result<AgentChangeset, LockRejection>> {
39    let changeset = agent_changesets::get_by_id_for_update(conn, changeset_id).await?;
40    if !changeset.is_open() {
41        return Ok(Err(LockRejection::NotOpen(changeset.status)));
42    }
43    if reviewed_revision.is_some_and(|reviewed| reviewed != changeset.revision) {
44        return Ok(Err(LockRejection::ChangedSinceReview(ChangedSinceReview {
45            revision: changeset.revision,
46        })));
47    }
48    Ok(Ok(changeset))
49}
50
51/// Why a discard was refused. Nothing was dropped.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
53#[serde(tag = "reason", rename_all = "snake_case")]
54pub enum DiscardRejection {
55    /// The changeset was published, discarded or expired before.
56    NotOpen { status: AgentChangesetStatus },
57    /// The staged changes changed after the revision the discard was based on.
58    ChangedSinceReview(ChangedSinceReview),
59    /// Without the entities to drop, the rest of the changeset would break these rules of
60    /// publishing.
61    Blockers { blockers: Vec<PublishBlocker> },
62}
63
64impl From<LockRejection> for DiscardRejection {
65    fn from(rejection: LockRejection) -> Self {
66        match rejection {
67            LockRejection::NotOpen(status) => Self::NotOpen { status },
68            LockRejection::ChangedSinceReview(changed) => Self::ChangedSinceReview(changed),
69        }
70    }
71}
72
73/// What discarding a whole changeset did.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
75#[serde(tag = "outcome", rename_all = "snake_case")]
76pub enum DiscardOutcome {
77    Discarded { changeset: AgentChangeset },
78    Rejected { rejection: DiscardRejection },
79}
80
81/// Drops every staged change of an open changeset. With `reviewed_revision`, refuses if the
82/// changeset is at another revision, so that changes staged after the user looked are not dropped
83/// unseen.
84pub async fn discard(
85    conn: &mut PgConnection,
86    changeset_id: Uuid,
87    reviewed_revision: Option<i32>,
88) -> ModelResult<DiscardOutcome> {
89    let mut tx = conn.begin().await?;
90    if let Err(rejection) = lock_open_changeset(&mut tx, changeset_id, reviewed_revision).await? {
91        tx.rollback().await?;
92        return Ok(DiscardOutcome::Rejected {
93            rejection: rejection.into(),
94        });
95    }
96    let changeset = agent_changesets::discard(&mut tx, changeset_id).await?;
97    tx.commit().await?;
98    Ok(DiscardOutcome::Discarded { changeset })
99}
100
101/// Drops the entities from an open changeset, so the user sees them live again, classifies the
102/// risk of the rest again and returns the entities it dropped. Entities the changeset doesn't
103/// have are skipped. Refuses like [`discard`], and if the rest would break rules of publishing
104/// the whole changeset didn't.
105pub async fn discard_entities(
106    conn: &mut PgConnection,
107    changeset_id: Uuid,
108    entities: &[EntityKey],
109    reviewed_revision: Option<i32>,
110    risk: &RiskBaseline<'_>,
111) -> ModelResult<Result<Vec<EntityRef>, DiscardRejection>> {
112    let mut tx = conn.begin().await?;
113    let changeset = match lock_open_changeset(&mut tx, changeset_id, reviewed_revision).await? {
114        Ok(changeset) => changeset,
115        Err(rejection) => {
116            tx.rollback().await?;
117            return Ok(Err(rejection.into()));
118        }
119    };
120    let mut state = risk.changeset_state(&mut tx, &changeset).await?;
121    let dropped: Vec<EntityKey> = entities
122        .iter()
123        .filter(|key| state.stored_reasons.contains_key(key))
124        .copied()
125        .collect();
126    if dropped.is_empty() {
127        tx.rollback().await?;
128        return Ok(Ok(Vec::new()));
129    }
130    let staged_before = state.staged.clone();
131    for key in &dropped {
132        state.stored_reasons.remove(key);
133        state.staged.to_mut().copy_entity_from(&state.live, *key);
134    }
135    let blockers = state.staged.blockers_added_since(&staged_before);
136    if !blockers.is_empty() {
137        tx.rollback().await?;
138        return Ok(Err(DiscardRejection::Blockers { blockers }));
139    }
140    let dropped_entities = agent_changeset_entities::get_by_keys(&mut tx, changeset_id, &dropped)
141        .await?
142        .iter()
143        .map(AgentChangesetEntity::entity_ref)
144        .collect();
145    agent_changeset_entities::delete(&mut tx, changeset_id, &dropped).await?;
146    agent_changesets::increment_revision(&mut tx, changeset_id).await?;
147    reclassify(&mut tx, changeset_id, &state).await?;
148    tx.commit().await?;
149    Ok(Ok(dropped_entities))
150}