headless_lms_models/library/agent_changesets/
review.rs1use 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#[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 pub field_changes: Vec<FieldChange>,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, ToSchema)]
41pub struct PageChange {
42 #[serde(flatten)]
43 pub change: EntityChange,
44 pub is_content_changed: bool,
46 pub base: Option<PreviewPage>,
48 pub after: Option<PreviewPage>,
50 pub warnings: Vec<ExerciseChangeWarning>,
52}
53
54#[derive(Debug, Clone, PartialEq, Serialize, ToSchema)]
56#[serde(tag = "kind", rename_all = "snake_case")]
57pub enum ExerciseChangeWarning {
58 ReviewConfigSwitchedWithReviews {
61 exercise_id: Uuid,
62 exercise_name: String,
63 to: ReviewConfigKind,
64 review_count: i64,
65 },
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, ToSchema)]
70pub struct CourseDefaultReviewConfigChange {
71 #[serde(flatten)]
72 pub change: EntityChange,
73 pub exercise_count: i64,
75}
76
77#[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 Fields(EntityChange),
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, ToSchema)]
89pub struct ChangesetReview {
90 pub changeset: AgentChangeset,
91 pub entities: Vec<ReviewEntity>,
92 pub calls: Vec<AgentChangesetCallSummary>,
94 pub conflicts: Vec<PublishConflict>,
96 pub is_expiring_soon: bool,
97 pub chapter_front_page_ids: Vec<Uuid>,
100}
101
102pub 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
193fn 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
207async 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}