1use std::borrow::Cow;
12use std::collections::{BTreeMap, HashMap, HashSet};
13
14use headless_lms_utils::document_schema_processor::walk;
15
16use crate::{
17 agent_changeset_entities::{self, AgentChangesetEntity, RiskReason},
18 agent_changesets::AgentChangeset,
19 course_module_completions, exercise_slide_submissions,
20 library::{
21 agent_changesets::course_state::{CourseParts, CourseState, CourseView, load_live_course},
22 entity_snapshots::{ContentEntityType, EntityKey},
23 },
24 prelude::*,
25};
26
27#[derive(Debug, Clone, Default)]
29pub struct StudentActivity {
30 pub exercises_with_submissions: HashSet<Uuid>,
31 pub pages_with_submissions: HashSet<Uuid>,
32 pub modules_with_completions: HashSet<Uuid>,
33}
34
35pub async fn load_student_activity(
37 conn: &mut PgConnection,
38 course_id: Uuid,
39) -> ModelResult<StudentActivity> {
40 let exercises =
41 exercise_slide_submissions::get_exercises_with_submissions_by_course_id(conn, course_id)
42 .await?;
43 let modules =
44 course_module_completions::get_completed_module_ids_by_course_id(conn, course_id).await?;
45 Ok(StudentActivity {
46 exercises_with_submissions: exercises.iter().map(|e| e.exercise_id).collect(),
47 pages_with_submissions: exercises.iter().map(|e| e.page_id).collect(),
48 modules_with_completions: modules.into_iter().collect(),
49 })
50}
51
52pub enum RiskBaseline<'a> {
55 Current,
57 View(&'a CourseView),
61}
62
63pub(super) struct ChangesetState<'a> {
66 pub live: Cow<'a, CourseState>,
67 pub staged: Cow<'a, CourseState>,
68 pub stored_reasons: HashMap<EntityKey, Vec<RiskReason>>,
70}
71
72impl<'a> ChangesetState<'a> {
73 pub fn of_rows(live: Cow<'a, CourseState>, rows: &[AgentChangesetEntity]) -> Self {
75 Self {
76 staged: Cow::Owned(live.overlay(rows, CourseParts::All)),
77 stored_reasons: rows
78 .iter()
79 .map(|row| (row.key(), row.risk_reasons.clone()))
80 .collect(),
81 live,
82 }
83 }
84}
85
86impl RiskBaseline<'_> {
87 pub(super) async fn changeset_state(
89 &self,
90 conn: &mut PgConnection,
91 changeset: &AgentChangeset,
92 ) -> ModelResult<ChangesetState<'_>> {
93 let view = match self {
94 RiskBaseline::View(view) => view,
95 RiskBaseline::Current => {
96 let course = crate::courses::get_course(conn, changeset.course_id).await?;
97 let live = load_live_course(conn, &course, CourseParts::All).await?;
98 let rows =
99 agent_changeset_entities::get_by_changeset_id(conn, changeset.id).await?;
100 return Ok(ChangesetState::of_rows(Cow::Owned(live), &rows));
101 }
102 };
103 let is_as_read = view
104 .changeset
105 .as_ref()
106 .is_some_and(|read| read.id == changeset.id && read.revision == changeset.revision);
107 if !is_as_read {
108 let rows = agent_changeset_entities::get_by_changeset_id(conn, changeset.id).await?;
109 return Ok(ChangesetState::of_rows(Cow::Borrowed(&view.live), &rows));
110 }
111 Ok(ChangesetState {
112 live: Cow::Borrowed(&view.live),
113 staged: Cow::Borrowed(&view.staged),
114 stored_reasons: view
115 .staged_entities
116 .iter()
117 .map(|(key, row)| (*key, row.risk_reasons.clone()))
118 .collect(),
119 })
120 }
121}
122
123pub fn classify(
125 live: &CourseState,
126 staged: &CourseState,
127 activity: &StudentActivity,
128 now: DateTime<Utc>,
129) -> HashMap<EntityKey, Vec<RiskReason>> {
130 if !live.is_live() {
131 return HashMap::new();
132 }
133 let mut classifier = Classifier {
134 live,
135 staged,
136 activity,
137 now,
138 reasons: HashMap::new(),
139 };
140 let pages_losing_submissions = classifier.classify_pages();
141 classifier.classify_chapters(&pages_losing_submissions);
142 classifier.classify_exercise_deadlines();
143 classifier.classify_modules();
144 classifier.classify_settings();
145 classifier.reasons
146}
147
148struct Classifier<'a> {
151 live: &'a CourseState,
152 staged: &'a CourseState,
153 activity: &'a StudentActivity,
154 now: DateTime<Utc>,
155 reasons: HashMap<EntityKey, Vec<RiskReason>>,
156}
157
158impl Classifier<'_> {
159 fn add(&mut self, entity_type: ContentEntityType, entity_id: Uuid, reason: RiskReason) {
160 self.reasons
161 .entry(EntityKey::new(entity_type, entity_id))
162 .or_default()
163 .push(reason);
164 }
165
166 fn chapter_is_open(&self, chapter_id: Uuid) -> bool {
167 crate::chapters::is_open_at(
168 self.live
169 .chapters
170 .iter()
171 .find(|c| c.id == chapter_id)
172 .and_then(|c| c.opens_at),
173 self.now,
174 )
175 }
176
177 fn page_is_live(&self, page_id: Uuid) -> bool {
179 self.live.page(page_id).is_none_or(|page| {
180 !page.hidden
181 && page
182 .chapter_id
183 .is_none_or(|chapter_id| self.chapter_is_open(chapter_id))
184 })
185 }
186
187 fn classify_pages(&mut self) -> HashSet<Uuid> {
189 let removed_pages: HashSet<Uuid> = self
190 .live
191 .pages
192 .iter()
193 .filter(|page| self.staged.page(page.id).is_none())
194 .map(|page| page.id)
195 .collect();
196 let mut changed_pages: HashSet<Uuid> = removed_pages.clone();
199 let mut staged_blocks: HashSet<Uuid> = HashSet::new();
200 for page in self.staged.pages_changed_since(self.live) {
201 changed_pages.insert(page.id);
202 walk(&page.content, &mut |block, _| {
203 staged_blocks.insert(block.client_id);
204 });
205 }
206 let mut removed_with_submissions: Vec<(Uuid, Uuid)> = Vec::new();
207 for page_id in &changed_pages {
208 if let Some(page) = self.live.page(*page_id) {
209 walk(&page.content, &mut |block, _| {
210 if !staged_blocks.contains(&block.client_id)
211 && let Some(exercise_id) = block.exercise_id()
212 && self
213 .activity
214 .exercises_with_submissions
215 .contains(&exercise_id)
216 {
217 removed_with_submissions.push((exercise_id, page.id));
218 }
219 });
220 }
221 }
222 let pages_losing_submissions: HashSet<Uuid> = removed_with_submissions
223 .iter()
224 .map(|(_, page)| *page)
225 .collect();
226 for (exercise_id, page) in removed_with_submissions {
227 if removed_pages.contains(&page) || !self.page_is_live(page) {
228 continue;
229 }
230 self.add(
231 ContentEntityType::Page,
232 page,
233 RiskReason::ExerciseWithSubmissionsRemoved { exercise_id },
234 );
235 }
236
237 for page in &self.live.pages {
238 let page = page.id;
239 if !removed_pages.contains(&page) || !self.page_is_live(page) {
240 continue;
241 }
242 self.add(
243 ContentEntityType::Page,
244 page,
245 if pages_losing_submissions.contains(&page) {
246 RiskReason::PageWithSubmissionsRemoved
247 } else {
248 RiskReason::VisiblePageRemoved
249 },
250 );
251 }
252 pages_losing_submissions
253 }
254
255 fn classify_chapters(&mut self, pages_losing_submissions: &HashSet<Uuid>) {
257 for live_chapter in &self.live.chapters {
258 let chapter = live_chapter.id;
259 if !self.chapter_is_open(chapter) {
260 continue;
261 }
262 let Some(staged_chapter) = self.staged.chapters.iter().find(|c| c.id == chapter) else {
263 let has_submissions = self.live.pages.iter().any(|page| {
264 page.chapter_id == Some(chapter) && pages_losing_submissions.contains(&page.id)
265 });
266 self.add(
267 ContentEntityType::Chapter,
268 chapter,
269 if has_submissions {
270 RiskReason::ChapterWithSubmissionsRemoved
271 } else {
272 RiskReason::OpenChapterRemoved
273 },
274 );
275 continue;
276 };
277 match (live_chapter.deadline, staged_chapter.deadline) {
278 (Some(before), Some(after)) if after < before => self.add(
279 ContentEntityType::Chapter,
280 chapter,
281 RiskReason::DeadlineMovedEarlier { before, after },
282 ),
283 (None, Some(deadline)) => self.add(
284 ContentEntityType::Chapter,
285 chapter,
286 RiskReason::DeadlineAddedToOpenChapter { deadline },
287 ),
288 _ => {}
289 }
290 if staged_chapter.opens_at != live_chapter.opens_at
292 && let Some(opens_at) = staged_chapter.opens_at.filter(|after| *after > self.now)
293 {
294 self.add(
295 ContentEntityType::Chapter,
296 chapter,
297 RiskReason::OpeningMovedLater { opens_at },
298 );
299 }
300 }
301 }
302
303 fn classify_exercise_deadlines(&mut self) {
305 let staged_exercises: BTreeMap<Uuid, Uuid> =
306 self.staged.exercise_page_ids().into_iter().collect();
307 let live_exercise_pages = self.live.exercise_page_ids();
308 for (exercise_id, page) in staged_exercises {
309 let (Some(live_deadline), Some(staged_deadline)) = (
310 self.live.exercise_deadlines.get(&exercise_id),
311 self.staged.exercise_deadlines.get(&exercise_id),
312 ) else {
313 continue;
314 };
315 let Some(live_page) = live_exercise_pages
316 .get(&exercise_id)
317 .copied()
318 .or_else(|| {
319 self.live
320 .exercise(exercise_id)
321 .map(|exercise| exercise.page_id)
322 })
323 .and_then(|id| self.live.page(id))
324 else {
325 continue;
326 };
327 let is_open = match live_deadline.chapter_id.or(live_page.chapter_id) {
328 Some(chapter) => !live_page.hidden && self.chapter_is_open(chapter),
329 None => self.page_is_live(live_page.id),
330 };
331 if live_deadline.own == staged_deadline.own || !is_open {
332 continue;
333 }
334 match (
335 self.live.effective_exercise_deadline(exercise_id),
336 self.staged.effective_exercise_deadline(exercise_id),
337 ) {
338 (Some(before), Some(after)) if after < before => self.add(
339 ContentEntityType::Page,
340 page,
341 RiskReason::ExerciseDeadlineMovedEarlier {
342 exercise_id,
343 before,
344 after,
345 },
346 ),
347 (None, Some(deadline)) => self.add(
348 ContentEntityType::Page,
349 page,
350 RiskReason::ExerciseDeadlineAdded {
351 exercise_id,
352 deadline,
353 },
354 ),
355 _ => {}
356 }
357 }
358 }
359
360 fn classify_modules(&mut self) {
362 for live_module in &self.live.modules {
363 let module = live_module.id;
364 let is_policy_changed = self
365 .staged
366 .modules
367 .iter()
368 .find(|m| m.id == module)
369 .is_some_and(|staged_module| {
370 staged_module.completion_policy != live_module.completion_policy
371 });
372 if is_policy_changed && self.activity.modules_with_completions.contains(&module) {
373 self.add(
374 ContentEntityType::Module,
375 module,
376 RiskReason::CompletionPolicyChangedAfterCompletions,
377 );
378 }
379 }
380 }
381
382 fn classify_settings(&mut self) {
384 let course_id = self.live.course_id();
385 if self
386 .staged
387 .settings
388 .base_module_completion_requires_n_submodule_completions
389 != self
390 .live
391 .settings
392 .base_module_completion_requires_n_submodule_completions
393 && !self.activity.modules_with_completions.is_empty()
394 {
395 self.add(
396 ContentEntityType::CourseSettings,
397 course_id,
398 RiskReason::CompletionRequirementChangedAfterCompletions,
399 );
400 }
401 if self.staged.settings.chapter_locking_enabled
402 != self.live.settings.chapter_locking_enabled
403 {
404 self.add(
405 ContentEntityType::CourseSettings,
406 course_id,
407 RiskReason::ChapterLockingToggled {
408 is_enabled: self.staged.settings.chapter_locking_enabled,
409 },
410 );
411 }
412 }
413}
414
415pub(super) struct Reclassified {
417 pub reasons: HashMap<EntityKey, Vec<RiskReason>>,
419 pub has_new_reasons: bool,
421 pub has_changed_reasons: bool,
422}
423
424impl Reclassified {
425 pub fn reason_count(&self) -> usize {
427 self.reasons.values().map(Vec::len).sum()
428 }
429}
430
431pub(super) async fn reclassify(
434 conn: &mut PgConnection,
435 changeset_id: Uuid,
436 state: &ChangesetState<'_>,
437) -> ModelResult<Reclassified> {
438 let live = state.live.as_ref();
439 let activity = if live.is_live() {
441 load_student_activity(conn, live.course_id()).await?
442 } else {
443 StudentActivity::default()
444 };
445 let reasons = classify(live, &state.staged, &activity, Utc::now());
446 let mut has_new_reasons = false;
447 let mut changed = Vec::new();
448 for (key, stored) in &state.stored_reasons {
449 let classified = reasons.get(key).map_or(&[][..], Vec::as_slice);
450 if classified != stored.as_slice() {
451 has_new_reasons |= classified.iter().any(|reason| !stored.contains(reason));
452 changed.push((*key, classified));
453 }
454 }
455 agent_changeset_entities::set_risk_reasons(conn, changeset_id, &changed).await?;
456 Ok(Reclassified {
457 has_changed_reasons: !changed.is_empty(),
458 reasons,
459 has_new_reasons,
460 })
461}