Skip to main content

headless_lms_models/library/agent_changesets/
review.rs

1//! What the review page shows of a changeset.
2
3use std::collections::HashMap;
4
5use utoipa::ToSchema;
6
7use crate::{
8    agent_changeset_calls::{self, AgentChangesetCallSummary},
9    agent_changeset_entities::{
10        self, AgentChangesetEntity, AgentChangesetEntityOperation, RiskReason,
11    },
12    agent_changesets::AgentChangeset,
13    library::{
14        agent_changesets::{
15            conflicts::{PublishConflict, conflicts_of},
16            course_state::{ExerciseRecord, ReviewConfigKind},
17            field_changes::{EntityField, FieldChange, FieldValue, field_changes},
18            preview::PreviewPage,
19        },
20        entity_snapshots::{
21            self, ChapterSnapshot, ContentEntity, ContentEntityType, EntityKey, EntityRef,
22            EntitySnapshot, PageSnapshot,
23        },
24    },
25    prelude::*,
26};
27
28/// What a change does to one entity, apart from a page's content.
29#[derive(Debug, Clone, PartialEq, Serialize, ToSchema)]
30pub struct EntityChange {
31    #[serde(flatten)]
32    pub entity: EntityRef,
33    pub operation: AgentChangesetEntityOperation,
34    pub risk_reasons: Vec<RiskReason>,
35    /// The fields that differ between the entity when it was staged and as staged.
36    pub field_changes: Vec<FieldChange>,
37}
38
39/// What a change does to a page: its fields, and its content as students see it.
40#[derive(Debug, Clone, PartialEq, Serialize, ToSchema)]
41pub struct PageChange {
42    #[serde(flatten)]
43    pub change: EntityChange,
44    /// Whether the blocks or exercises differ; always for creates and deletes.
45    pub is_content_changed: bool,
46    /// The page when it was staged; `None` for creates.
47    pub base: Option<PreviewPage>,
48    /// The page as staged; `None` for deletes.
49    pub after: Option<PreviewPage>,
50    /// What the reviewer should know before publishing the page's exercise changes.
51    pub warnings: Vec<ExerciseChangeWarning>,
52}
53
54/// A consequence of an exercise change the reviewer should know of. Never makes the change risky.
55#[derive(Debug, Clone, PartialEq, Serialize, ToSchema)]
56#[serde(tag = "kind", rename_all = "snake_case")]
57pub enum ExerciseChangeWarning {
58    /// The exercise switches between the course default review config and its own while students
59    /// have already given reviews under the old one.
60    ReviewConfigSwitchedWithReviews {
61        exercise_id: Uuid,
62        exercise_name: String,
63        to: ReviewConfigKind,
64        review_count: i64,
65    },
66}
67
68/// A change to the course default review config, which every exercise without its own uses.
69#[derive(Debug, Clone, PartialEq, Serialize, ToSchema)]
70pub struct CourseDefaultReviewConfigChange {
71    #[serde(flatten)]
72    pub change: EntityChange,
73    /// How many published exercises with peer or self reviews use the course default.
74    pub exercise_count: i64,
75}
76
77/// One staged entity on the review page.
78#[derive(Debug, Clone, PartialEq, Serialize, ToSchema)]
79#[serde(tag = "kind", rename_all = "snake_case")]
80pub enum ReviewEntity {
81    Page(Box<PageChange>),
82    CourseDefaultReviewConfig(Box<CourseDefaultReviewConfigChange>),
83    /// Any other entity, which changes only field by field.
84    Fields(EntityChange),
85}
86
87/// Everything the review page shows for a changeset.
88#[derive(Debug, Clone, PartialEq, Serialize, ToSchema)]
89pub struct ChangesetReview {
90    pub changeset: AgentChangeset,
91    pub entities: Vec<ReviewEntity>,
92    /// The agent calls that staged the changes, oldest first.
93    pub calls: Vec<AgentChangesetCallSummary>,
94    /// Staged entities whose live state changed after they were staged, which blocks publishing.
95    pub conflicts: Vec<PublishConflict>,
96    pub is_expiring_soon: bool,
97    /// The pages that are their chapter's front page, as staged while the changeset is open and
98    /// as live after.
99    pub chapter_front_page_ids: Vec<Uuid>,
100}
101
102/// The review of a changeset, with every reference in a field change labelled.
103pub async fn get_review(
104    conn: &mut PgConnection,
105    changeset: AgentChangeset,
106) -> ModelResult<ChangesetReview> {
107    let rows = agent_changeset_entities::get_by_changeset_id(conn, changeset.id).await?;
108    let keys: Vec<EntityKey> = rows.iter().map(AgentChangesetEntity::key).collect();
109    let mut bases = agent_changeset_entities::get_bases(conn, changeset.id, &keys).await?;
110    let conflicts = if changeset.is_open() {
111        conflicts_of(conn, &rows, &bases).await?
112    } else {
113        Vec::new()
114    };
115    let chapter_front_page_ids = chapter_front_page_ids(conn, &changeset, &rows).await?;
116    let mut labels: HashMap<EntityKey, String> = rows
117        .iter()
118        .map(|row| (row.key(), row.label.clone()))
119        .collect();
120    let exercises_before: HashMap<Uuid, ExerciseRecord> = bases
121        .values()
122        .filter_map(PageSnapshot::narrow)
123        .flat_map(ExerciseRecord::of_page)
124        .map(|exercise| (exercise.id, exercise))
125        .collect();
126    let mut entities: Vec<ReviewEntity> = rows
127        .into_iter()
128        .map(|row| review_entity(bases.remove(&row.key()), row, &exercises_before))
129        .collect();
130    add_review_config_warnings(conn, &mut entities).await?;
131    for entity in entities.iter_mut() {
132        if let ReviewEntity::CourseDefaultReviewConfig(config) = entity {
133            config.exercise_count = crate::exercises::count_using_course_default_review_config(
134                conn,
135                changeset.course_id,
136            )
137            .await?;
138        }
139    }
140
141    let unlabelled: Vec<EntityKey> = references_mut(&mut entities)
142        .map(|entity| entity.key())
143        .filter(|key| !labels.contains_key(key))
144        .collect();
145    for (key, snapshot) in entity_snapshots::load_live_many(conn, &unlabelled).await? {
146        labels.insert(
147            key,
148            snapshot.map(|s| s.label().to_string()).unwrap_or_default(),
149        );
150    }
151    for entity in references_mut(&mut entities) {
152        entity.label = labels.get(&entity.key()).cloned().unwrap_or_default();
153    }
154
155    let calls = agent_changeset_calls::get_summaries_by_changeset_id(conn, changeset.id).await?;
156    Ok(ChangesetReview {
157        is_expiring_soon: changeset.is_expiring_soon(Utc::now()),
158        changeset,
159        entities,
160        calls,
161        conflicts,
162        chapter_front_page_ids,
163    })
164}
165
166async fn chapter_front_page_ids(
167    conn: &mut PgConnection,
168    changeset: &AgentChangeset,
169    rows: &[AgentChangesetEntity],
170) -> ModelResult<Vec<Uuid>> {
171    let mut front_pages: HashMap<Uuid, Option<Uuid>> =
172        crate::chapters::get_course_chapters(conn, changeset.course_id)
173            .await?
174            .into_iter()
175            .map(|chapter| (chapter.id, chapter.front_page_id))
176            .collect();
177    if changeset.is_open() {
178        for row in rows
179            .iter()
180            .filter(|row| row.entity_type == ContentEntityType::Chapter)
181        {
182            let front_page = row
183                .after_snapshot
184                .as_ref()
185                .and_then(ChapterSnapshot::narrow)
186                .and_then(|chapter| chapter.front_page_id);
187            front_pages.insert(row.entity_id, front_page);
188        }
189    }
190    Ok(front_pages.into_values().flatten().collect())
191}
192
193/// The entities the field changes point at.
194fn references_mut(entities: &mut [ReviewEntity]) -> impl Iterator<Item = &mut EntityRef> {
195    entities
196        .iter_mut()
197        .flat_map(|entity| match entity {
198            ReviewEntity::Page(page) => page.change.field_changes.iter_mut(),
199            ReviewEntity::CourseDefaultReviewConfig(config) => {
200                config.change.field_changes.iter_mut()
201            }
202            ReviewEntity::Fields(change) => change.field_changes.iter_mut(),
203        })
204        .flat_map(FieldChange::references_mut)
205}
206
207/// Warns of each exercise whose review config switch drops reviews students have given.
208async fn add_review_config_warnings(
209    conn: &mut PgConnection,
210    entities: &mut [ReviewEntity],
211) -> ModelResult<()> {
212    let switches = |page: &PageChange| -> Vec<(Uuid, String, ReviewConfigKind)> {
213        page.change
214            .field_changes
215            .iter()
216            .filter(|change| change.field == EntityField::ReviewConfig)
217            .filter_map(|change| {
218                let owner = change.exercise.as_ref()?;
219                let Some(FieldValue::ReviewConfig(to)) = change.after else {
220                    return None;
221                };
222                Some((owner.exercise_id, owner.exercise_name.clone(), to))
223            })
224            .collect()
225    };
226    let exercise_ids: Vec<Uuid> = entities
227        .iter()
228        .filter_map(|entity| match entity {
229            ReviewEntity::Page(page) => Some(switches(page)),
230            _ => None,
231        })
232        .flatten()
233        .map(|(id, _, _)| id)
234        .collect();
235    let review_counts =
236        crate::peer_or_self_review_submissions::count_by_exercise_ids(conn, &exercise_ids).await?;
237    for entity in entities.iter_mut() {
238        if let ReviewEntity::Page(page) = entity {
239            for (exercise_id, exercise_name, to) in switches(page) {
240                if let Some(review_count) = review_counts.get(&exercise_id) {
241                    page.warnings
242                        .push(ExerciseChangeWarning::ReviewConfigSwitchedWithReviews {
243                            exercise_id,
244                            exercise_name,
245                            to,
246                            review_count: *review_count,
247                        });
248                }
249            }
250        }
251    }
252    Ok(())
253}
254
255fn review_entity(
256    base: Option<EntitySnapshot>,
257    row: AgentChangesetEntity,
258    exercises_before: &HashMap<Uuid, ExerciseRecord>,
259) -> ReviewEntity {
260    let change = EntityChange {
261        entity: row.entity_ref(),
262        operation: row.operation(),
263        field_changes: field_changes(base.as_ref(), row.after_snapshot.as_ref(), exercises_before),
264        risk_reasons: row.risk_reasons,
265    };
266    if row.entity_type == ContentEntityType::CourseDefaultReviewConfig {
267        return ReviewEntity::CourseDefaultReviewConfig(Box::new(
268            CourseDefaultReviewConfigChange {
269                change,
270                exercise_count: 0,
271            },
272        ));
273    }
274    let as_page = |snapshot: Option<EntitySnapshot>| match snapshot {
275        Some(EntitySnapshot::Page(page)) => Some(page),
276        _ => None,
277    };
278    let (base, after) = (as_page(base), as_page(row.after_snapshot));
279    if base.is_none() && after.is_none() {
280        return ReviewEntity::Fields(change);
281    }
282    let is_content_changed = match (&base, &after) {
283        (Some(base), Some(after)) => {
284            base.content != after.content
285                || base.exercises != after.exercises
286                || base.exercise_slides != after.exercise_slides
287                || base.exercise_tasks != after.exercise_tasks
288        }
289        _ => true,
290    };
291    ReviewEntity::Page(Box::new(PageChange {
292        change,
293        is_content_changed,
294        base: base.map(PreviewPage::from),
295        after: after.map(PreviewPage::from),
296        warnings: Vec::new(),
297    }))
298}