headless_lms_models/library/agent_changesets/
locking.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
21pub struct ChangedSinceReview {
22 pub revision: i32,
24}
25
26pub(super) enum LockRejection {
28 NotOpen(AgentChangesetStatus),
29 ChangedSinceReview(ChangedSinceReview),
30}
31
32pub(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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
53#[serde(tag = "reason", rename_all = "snake_case")]
54pub enum DiscardRejection {
55 NotOpen { status: AgentChangesetStatus },
57 ChangedSinceReview(ChangedSinceReview),
59 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#[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
81pub 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
101pub 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}