1use std::collections::HashMap;
4
5use sqlx::types::Json;
6use utoipa::ToSchema;
7
8use crate::{
9 library::entity_snapshots::{ContentEntityType, EntityKey, EntityRef, EntitySnapshot},
10 prelude::*,
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
14#[serde(rename_all = "snake_case")]
15pub enum AgentChangesetEntityOperation {
16 Create,
17 Update,
18 Delete,
19}
20
21impl AgentChangesetEntityOperation {
22 pub fn between(exists_before: bool, exists_after: bool) -> Self {
25 match (exists_before, exists_after) {
26 (false, true) => Self::Create,
27 (true, false) => Self::Delete,
28 (true, true) | (false, false) => Self::Update,
29 }
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
37#[serde(tag = "code", rename_all = "snake_case")]
38pub enum RiskReason {
39 ExerciseWithSubmissionsRemoved { exercise_id: Uuid },
41 PageWithSubmissionsRemoved,
43 ChapterWithSubmissionsRemoved,
45 VisiblePageRemoved,
47 OpenChapterRemoved,
49 DeadlineMovedEarlier {
51 before: DateTime<Utc>,
52 after: DateTime<Utc>,
53 },
54 DeadlineAddedToOpenChapter { deadline: DateTime<Utc> },
56 ExerciseDeadlineMovedEarlier {
59 exercise_id: Uuid,
60 before: DateTime<Utc>,
61 after: DateTime<Utc>,
62 },
63 ExerciseDeadlineAdded {
65 exercise_id: Uuid,
66 deadline: DateTime<Utc>,
67 },
68 OpeningMovedLater { opens_at: DateTime<Utc> },
70 CompletionPolicyChangedAfterCompletions,
72 CompletionRequirementChangedAfterCompletions,
75 ChapterLockingToggled { is_enabled: bool },
77}
78
79#[derive(Debug, Clone, PartialEq)]
82pub struct AgentChangesetEntity {
83 pub entity_type: ContentEntityType,
84 pub entity_id: Uuid,
85 pub has_base: bool,
87 pub after_snapshot: Option<EntitySnapshot>,
89 pub label: String,
91 pub risk_reasons: Vec<RiskReason>,
92 pub revision: i32,
94}
95
96impl AgentChangesetEntity {
97 pub fn key(&self) -> EntityKey {
99 EntityKey::new(self.entity_type, self.entity_id)
100 }
101
102 pub fn entity_ref(&self) -> EntityRef {
104 EntityRef {
105 entity_type: self.entity_type,
106 entity_id: self.entity_id,
107 label: self.label.clone(),
108 }
109 }
110
111 pub fn operation(&self) -> AgentChangesetEntityOperation {
113 AgentChangesetEntityOperation::between(self.has_base, self.after_snapshot.is_some())
114 }
115}
116
117struct AgentChangesetEntityRow {
118 entity_type: ContentEntityType,
119 entity_id: Uuid,
120 has_base: bool,
121 after_snapshot: Option<String>,
122 label: String,
123 risk_reasons: Json<Vec<RiskReason>>,
124 revision: i32,
125}
126
127impl TryFrom<AgentChangesetEntityRow> for AgentChangesetEntity {
128 type Error = ModelError;
129
130 fn try_from(row: AgentChangesetEntityRow) -> Result<Self, Self::Error> {
131 Ok(Self {
132 entity_type: row.entity_type,
133 entity_id: row.entity_id,
134 has_base: row.has_base,
135 after_snapshot: row
136 .after_snapshot
137 .map(|json| EntitySnapshot::from_json(row.entity_type, &json))
138 .transpose()?,
139 label: row.label,
140 risk_reasons: row.risk_reasons.0,
141 revision: row.revision,
142 })
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct NewStagedEntity {
149 pub key: EntityKey,
150 pub base_json: Option<String>,
152 pub after_json: Option<String>,
154 pub label: String,
155}
156
157#[derive(Debug, Clone)]
159pub struct RestagedEntity {
160 pub key: EntityKey,
161 pub after_json: Option<String>,
163 pub label: String,
164}
165
166pub async fn insert(
169 conn: &mut PgConnection,
170 changeset_id: Uuid,
171 revision: i32,
172 entities: &[NewStagedEntity],
173) -> ModelResult<()> {
174 if entities.is_empty() {
175 return Ok(());
176 }
177 let mut entity_types = Vec::with_capacity(entities.len());
178 let mut entity_ids = Vec::with_capacity(entities.len());
179 let mut bases = Vec::with_capacity(entities.len());
180 let mut afters = Vec::with_capacity(entities.len());
181 let mut labels = Vec::with_capacity(entities.len());
182 for entity in entities {
183 entity_types.push(entity.key.entity_type);
184 entity_ids.push(entity.key.entity_id);
185 bases.push(entity.base_json.as_deref());
186 afters.push(entity.after_json.as_deref());
187 labels.push(entity.label.as_str());
188 }
189 sqlx::query!(
190 r#"
191INSERT INTO agent_changeset_entities (
192 changeset_id,
193 entity_type,
194 entity_id,
195 base_snapshot,
196 after_snapshot,
197 label,
198 revision
199 )
200SELECT $1,
201 k.entity_type,
202 k.entity_id,
203 k.base::jsonb,
204 k.after::jsonb,
205 k.label,
206 $2
207FROM UNNEST(
208 $3::content_entity_type [],
209 $4::uuid [],
210 $5::text [],
211 $6::text [],
212 $7::text []
213 ) AS k(entity_type, entity_id, base, after, label)
214 "#,
215 changeset_id,
216 revision,
217 entity_types as Vec<ContentEntityType>,
218 &entity_ids,
219 &bases as &[Option<&str>],
220 &afters as &[Option<&str>],
221 &labels as &[&str],
222 )
223 .execute(conn)
224 .await?;
225 Ok(())
226}
227
228pub async fn restage(
232 conn: &mut PgConnection,
233 changeset_id: Uuid,
234 revision: i32,
235 entities: &[RestagedEntity],
236) -> ModelResult<()> {
237 if entities.is_empty() {
238 return Ok(());
239 }
240 let mut entity_types = Vec::with_capacity(entities.len());
241 let mut entity_ids = Vec::with_capacity(entities.len());
242 let mut afters = Vec::with_capacity(entities.len());
243 let mut labels = Vec::with_capacity(entities.len());
244 for entity in entities {
245 entity_types.push(entity.key.entity_type);
246 entity_ids.push(entity.key.entity_id);
247 afters.push(entity.after_json.as_deref());
248 labels.push(entity.label.as_str());
249 }
250 sqlx::query!(
251 r#"
252UPDATE agent_changeset_entities e
253SET after_snapshot = k.after::jsonb,
254 label = k.label,
255 revision = $2
256FROM UNNEST(
257 $3::content_entity_type [],
258 $4::uuid [],
259 $5::text [],
260 $6::text []
261 ) AS k(entity_type, entity_id, after, label)
262WHERE e.changeset_id = $1
263 AND e.entity_type = k.entity_type
264 AND e.entity_id = k.entity_id
265 "#,
266 changeset_id,
267 revision,
268 entity_types as Vec<ContentEntityType>,
269 &entity_ids,
270 &afters as &[Option<&str>],
271 &labels as &[&str],
272 )
273 .execute(conn)
274 .await?;
275 Ok(())
276}
277
278pub async fn set_risk_reasons(
281 conn: &mut PgConnection,
282 changeset_id: Uuid,
283 reasons: &[(EntityKey, &[RiskReason])],
284) -> ModelResult<()> {
285 if reasons.is_empty() {
286 return Ok(());
287 }
288 let mut entity_types = Vec::with_capacity(reasons.len());
289 let mut entity_ids = Vec::with_capacity(reasons.len());
290 let mut values = Vec::with_capacity(reasons.len());
291 for (
292 EntityKey {
293 entity_type,
294 entity_id,
295 },
296 entity_reasons,
297 ) in reasons
298 {
299 entity_types.push(*entity_type);
300 entity_ids.push(*entity_id);
301 values.push(serde_json::to_value(entity_reasons)?);
302 }
303 sqlx::query!(
304 r#"
305UPDATE agent_changeset_entities e
306SET risk_reasons = k.risk_reasons
307FROM UNNEST($2::content_entity_type [], $3::uuid [], $4::jsonb []) AS k(entity_type, entity_id, risk_reasons)
308WHERE e.changeset_id = $1
309 AND e.entity_type = k.entity_type
310 AND e.entity_id = k.entity_id
311 "#,
312 changeset_id,
313 entity_types as Vec<ContentEntityType>,
314 &entity_ids,
315 &values,
316 )
317 .execute(conn)
318 .await?;
319 Ok(())
320}
321
322pub async fn delete(
324 conn: &mut PgConnection,
325 changeset_id: Uuid,
326 keys: &[EntityKey],
327) -> ModelResult<()> {
328 if keys.is_empty() {
329 return Ok(());
330 }
331 let (entity_types, entity_ids) = EntityKey::unzip(keys);
332 sqlx::query!(
333 r#"
334DELETE FROM agent_changeset_entities e USING UNNEST($2::content_entity_type [], $3::uuid []) AS k(entity_type, entity_id)
335WHERE e.changeset_id = $1
336 AND e.entity_type = k.entity_type
337 AND e.entity_id = k.entity_id
338 "#,
339 changeset_id,
340 entity_types as Vec<ContentEntityType>,
341 &entity_ids,
342 )
343 .execute(conn)
344 .await?;
345 Ok(())
346}
347
348pub async fn get_by_changeset_id(
350 conn: &mut PgConnection,
351 changeset_id: Uuid,
352) -> ModelResult<Vec<AgentChangesetEntity>> {
353 let rows = sqlx::query_as!(
354 AgentChangesetEntityRow,
355 r#"
356SELECT entity_type,
357 entity_id,
358 base_snapshot IS NOT NULL AS "has_base!",
359 after_snapshot::text AS "after_snapshot?",
360 label,
361 risk_reasons,
362 revision
363FROM agent_changeset_entities
364WHERE changeset_id = $1
365ORDER BY created_at,
366 id
367 "#,
368 changeset_id,
369 )
370 .fetch_all(conn)
371 .await?;
372 rows.into_iter().map(TryInto::try_into).collect()
373}
374
375pub async fn get_by_keys(
377 conn: &mut PgConnection,
378 changeset_id: Uuid,
379 keys: &[EntityKey],
380) -> ModelResult<Vec<AgentChangesetEntity>> {
381 let (entity_types, entity_ids) = EntityKey::unzip(keys);
382 let rows = sqlx::query_as!(
383 AgentChangesetEntityRow,
384 r#"
385SELECT e.entity_type,
386 e.entity_id,
387 e.base_snapshot IS NOT NULL AS "has_base!",
388 e.after_snapshot::text AS "after_snapshot?",
389 e.label,
390 e.risk_reasons,
391 e.revision
392FROM agent_changeset_entities e
393 JOIN UNNEST($2::content_entity_type [], $3::uuid []) AS k(entity_type, entity_id)
394 ON k.entity_type = e.entity_type
395 AND k.entity_id = e.entity_id
396WHERE e.changeset_id = $1
397 "#,
398 changeset_id,
399 entity_types as Vec<ContentEntityType>,
400 &entity_ids,
401 )
402 .fetch_all(conn)
403 .await?;
404 rows.into_iter().map(TryInto::try_into).collect()
405}
406
407pub async fn get_one(
409 conn: &mut PgConnection,
410 changeset_id: Uuid,
411 key: EntityKey,
412) -> ModelResult<Option<AgentChangesetEntity>> {
413 Ok(get_by_keys(conn, changeset_id, &[key]).await?.pop())
414}
415
416pub async fn get_operation(
419 conn: &mut PgConnection,
420 changeset_id: Uuid,
421 EntityKey {
422 entity_type,
423 entity_id,
424 }: EntityKey,
425) -> ModelResult<Option<AgentChangesetEntityOperation>> {
426 let row = sqlx::query!(
427 r#"
428SELECT base_snapshot IS NOT NULL AS "has_base!",
429 after_snapshot IS NOT NULL AS "has_after!"
430FROM agent_changeset_entities
431WHERE changeset_id = $1
432 AND entity_type = $2
433 AND entity_id = $3
434 "#,
435 changeset_id,
436 entity_type as ContentEntityType,
437 entity_id,
438 )
439 .fetch_optional(conn)
440 .await?;
441 Ok(row.map(|row| AgentChangesetEntityOperation::between(row.has_base, row.has_after)))
442}
443
444pub async fn get_bases(
447 conn: &mut PgConnection,
448 changeset_id: Uuid,
449 keys: &[EntityKey],
450) -> ModelResult<HashMap<EntityKey, EntitySnapshot>> {
451 if keys.is_empty() {
452 return Ok(HashMap::new());
453 }
454 let (entity_types, entity_ids) = EntityKey::unzip(keys);
455 let rows = sqlx::query!(
456 r#"
457SELECT e.entity_type,
458 e.entity_id,
459 e.base_snapshot::text AS "base_snapshot!"
460FROM agent_changeset_entities e
461 JOIN UNNEST($2::content_entity_type [], $3::uuid []) AS k(entity_type, entity_id)
462 ON k.entity_type = e.entity_type
463 AND k.entity_id = e.entity_id
464WHERE e.changeset_id = $1
465 AND e.base_snapshot IS NOT NULL
466 "#,
467 changeset_id,
468 entity_types as Vec<ContentEntityType>,
469 &entity_ids,
470 )
471 .fetch_all(conn)
472 .await?;
473 rows.into_iter()
474 .map(|row| {
475 let snapshot = EntitySnapshot::from_json(row.entity_type, &row.base_snapshot)?;
476 Ok((EntityKey::new(row.entity_type, row.entity_id), snapshot))
477 })
478 .collect()
479}
480
481#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
483pub struct ChangesetEntitySummary {
484 #[serde(flatten)]
485 pub entity: EntityRef,
486 pub operation: AgentChangesetEntityOperation,
487 #[serde(skip_serializing_if = "Vec::is_empty", default)]
488 pub risk_reasons: Vec<RiskReason>,
489}
490
491impl From<&AgentChangesetEntity> for ChangesetEntitySummary {
492 fn from(entity: &AgentChangesetEntity) -> Self {
493 Self {
494 entity: entity.entity_ref(),
495 operation: entity.operation(),
496 risk_reasons: entity.risk_reasons.clone(),
497 }
498 }
499}
500
501pub async fn get_summaries_by_changeset_ids(
504 conn: &mut PgConnection,
505 changeset_ids: &[Uuid],
506) -> ModelResult<Vec<(Uuid, ChangesetEntitySummary)>> {
507 let rows = sqlx::query!(
508 r#"
509SELECT changeset_id,
510 entity_type,
511 entity_id,
512 base_snapshot IS NOT NULL AS "has_base!",
513 after_snapshot IS NOT NULL AS "has_after!",
514 label,
515 risk_reasons
516FROM agent_changeset_entities
517WHERE changeset_id = ANY($1)
518ORDER BY created_at,
519 id
520 "#,
521 changeset_ids,
522 )
523 .fetch_all(conn)
524 .await?;
525 Ok(rows
526 .into_iter()
527 .map(|row| {
528 let summary = ChangesetEntitySummary {
529 entity: EntityRef {
530 entity_type: row.entity_type,
531 entity_id: row.entity_id,
532 label: row.label,
533 },
534 operation: AgentChangesetEntityOperation::between(row.has_base, row.has_after),
535 risk_reasons: row.risk_reasons.0,
536 };
537 (row.changeset_id, summary)
538 })
539 .collect())
540}