Skip to main content

headless_lms_models/library/agent_changesets/
risk.rs

1//! Risk rules: which staged changes only the user may publish, on the review page.
2//!
3//! Only live content is risky. Draft and test-mode courses, hidden pages and chapters that haven't
4//! opened yet are free. Size alone never makes a change risky, and moving exercises never does:
5//! an exercise's deadline counts only when its own deadline field changes.
6//!
7//! Risk belongs to the whole changeset against the live course, so it is judged on every staged
8//! entity at once: an exercise moved off its page by one call and removed by another is still a
9//! removal.
10
11use 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/// Published facts about student activity that make a change risky.
28#[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
35/// Which exercises have submissions and which modules have completions.
36pub 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
52/// What the caller has already loaded of the course a changeset's risk and publish blockers are
53/// judged on.
54pub enum RiskBaseline<'a> {
55    /// Nothing: the course and the changeset are loaded in the caller's transaction.
56    Current,
57    /// The user's view of the course, with every part loaded. Its live course is the baseline, and
58    /// its staged state stands in for the changeset's while the changeset is at the revision the
59    /// view read.
60    View(&'a CourseView),
61}
62
63/// A changeset as its risk and publish blockers are judged: the live course, the course with the
64/// changeset laid over it, and the risk reasons stored with each staged entity.
65pub(super) struct ChangesetState<'a> {
66    pub live: Cow<'a, CourseState>,
67    pub staged: Cow<'a, CourseState>,
68    /// By staged entity; every entity the changeset stages has an entry.
69    pub stored_reasons: HashMap<EntityKey, Vec<RiskReason>>,
70}
71
72impl<'a> ChangesetState<'a> {
73    /// The state of a changeset with the staged `rows` over `live`.
74    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    /// The state of `changeset`, which the caller has locked.
88    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
123/// The risk reasons of each entity `staged` changes from `live`, at `now`.
124pub 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
148/// The state of one [`classify`] call. The rule families run in a fixed order, since an entity's
149/// reasons are compared with the stored ones as a list.
150struct 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    /// Students can see the page now.
178    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    /// Removed pages and exercises with submissions. Returns the pages that lose submissions.
188    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        // An exercise moved elsewhere keeps its blocks' client ids, so only blocks gone from every
197        // changed page count as removed.
198        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    /// Removed open chapters, and deadlines and openings of open chapters.
256    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            // The chapter is open now, so only an opening time still ahead closes it.
291            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    /// Own deadlines of exercises students can answer now.
304    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    /// Completion policies of modules with completions.
361    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    /// The course's completion requirement and chapter locking.
383    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
415/// A changeset's risk reasons classified again.
416pub(super) struct Reclassified {
417    /// By staged entity; entities without reasons are left out.
418    pub reasons: HashMap<EntityKey, Vec<RiskReason>>,
419    /// Whether the new classification has a reason the stored one didn't.
420    pub has_new_reasons: bool,
421    pub has_changed_reasons: bool,
422}
423
424impl Reclassified {
425    /// How many reasons the whole changeset holds.
426    pub fn reason_count(&self) -> usize {
427        self.reasons.values().map(Vec::len).sum()
428    }
429}
430
431/// Classifies the changeset's staged state again, against its live course and student activity
432/// now, and stores the reasons that changed.
433pub(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    // No rule looks at the activity of a course students can't see.
440    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}