Skip to main content

headless_lms_models/
agent_changeset_entities.rs

1//! The pages and entities an agent changeset changes, with their staged snapshots.
2
3use 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    /// The operation implied by whether the entity exists before and after. Staged rows and logged
23    /// calls always have one side, so neither-side counts as an update.
24    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/// Why a staged change needs the user's confirmation before it is published: a rule it breaks,
34/// with the facts the rule found. It belongs to the entity the change is to, so it doesn't name
35/// that entity again.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
37#[serde(tag = "code", rename_all = "snake_case")]
38pub enum RiskReason {
39    /// The page loses an exercise that has submissions.
40    ExerciseWithSubmissionsRemoved { exercise_id: Uuid },
41    /// The page is removed, with exercises that have submissions.
42    PageWithSubmissionsRemoved,
43    /// The chapter is removed, with exercises that have submissions.
44    ChapterWithSubmissionsRemoved,
45    /// The page is removed while students can see it.
46    VisiblePageRemoved,
47    /// The chapter is removed while it is open.
48    OpenChapterRemoved,
49    /// The open chapter's deadline moves earlier.
50    DeadlineMovedEarlier {
51        before: DateTime<Utc>,
52        after: DateTime<Utc>,
53    },
54    /// The open chapter gets a deadline.
55    DeadlineAddedToOpenChapter { deadline: DateTime<Utc> },
56    /// The deadline that applies to the exercise on the page, open to students, moves earlier
57    /// through a change to the exercise's own deadline.
58    ExerciseDeadlineMovedEarlier {
59        exercise_id: Uuid,
60        before: DateTime<Utc>,
61        after: DateTime<Utc>,
62    },
63    /// The exercise on the page, open to students and without a deadline so far, gets its own.
64    ExerciseDeadlineAdded {
65        exercise_id: Uuid,
66        deadline: DateTime<Utc>,
67    },
68    /// The open chapter opens again only later.
69    OpeningMovedLater { opens_at: DateTime<Utc> },
70    /// The module's completion policy changes after students have completed it.
71    CompletionPolicyChangedAfterCompletions,
72    /// The number of module completions the base module requires changes after modules have
73    /// completions.
74    CompletionRequirementChangedAfterCompletions,
75    /// Chapter locking is turned on, or off.
76    ChapterLockingToggled { is_enabled: bool },
77}
78
79/// One entity of a changeset with its staged state. Its state when first staged, which only
80/// publishing and the review need, is read with [`get_bases`].
81#[derive(Debug, Clone, PartialEq)]
82pub struct AgentChangesetEntity {
83    pub entity_type: ContentEntityType,
84    pub entity_id: Uuid,
85    /// Whether the entity existed live when it was first staged.
86    pub has_base: bool,
87    /// The staged entity; `None` for deletes.
88    pub after_snapshot: Option<EntitySnapshot>,
89    /// See [`EntityRef::label`].
90    pub label: String,
91    pub risk_reasons: Vec<RiskReason>,
92    /// The changeset revision of the last write to this row.
93    pub revision: i32,
94}
95
96impl AgentChangesetEntity {
97    /// The row's entity as a key.
98    pub fn key(&self) -> EntityKey {
99        EntityKey::new(self.entity_type, self.entity_id)
100    }
101
102    /// The row's entity with its label.
103    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    /// What publishing does to the live entity.
112    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/// An entity a changeset stages for the first time; see [`insert`].
147#[derive(Debug, Clone)]
148pub struct NewStagedEntity {
149    pub key: EntityKey,
150    /// The snapshot JSON of the live entity the stage builds on; `None` for creates.
151    pub base_json: Option<String>,
152    /// The staged snapshot JSON; `None` for deletes.
153    pub after_json: Option<String>,
154    pub label: String,
155}
156
157/// A new staged state of an entity the changeset already has; see [`restage`].
158#[derive(Debug, Clone)]
159pub struct RestagedEntity {
160    pub key: EntityKey,
161    /// The staged snapshot JSON; `None` for deletes.
162    pub after_json: Option<String>,
163    pub label: String,
164}
165
166/// Adds the entities to the changeset, written at `revision`. Risk reasons are left to
167/// [`set_risk_reasons`].
168pub 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
228/// Replaces the staged state of entities the changeset has, written at `revision`. Each row keeps
229/// its base, the live state its first stage built on. Risk reasons are left to
230/// [`set_risk_reasons`].
231pub 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
278/// Replaces the risk reasons of staged entities, leaving their staged state and revision as they
279/// are.
280pub 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
322/// Removes the entities from the changeset.
323pub 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
348/// All staged rows of the changeset, oldest first.
349pub 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
375/// The changeset's rows for the given entities; entities it doesn't have are left out.
376pub 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
407/// The changeset's row for the entity; `None` if it doesn't stage it. See [`get_by_keys`].
408pub 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
416/// What publishing the changeset would do to the entity; `None` if the changeset doesn't stage
417/// it.
418pub 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
444/// The live state each of the given entities had when the changeset first staged it, by key;
445/// creates and entities the changeset doesn't have are left out.
446pub 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/// One entity of a changeset, as listings and expiry warnings describe it.
482#[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
501/// Every entity of the changesets as listings describe it, with the id of its changeset, oldest
502/// first.
503pub 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}