Skip to main content

headless_lms_models/library/agent_changesets/course_state/
blockers.rs

1use std::collections::{BTreeMap, HashSet};
2
3use utoipa::ToSchema;
4
5use super::CourseState;
6use crate::{
7    chapters,
8    library::entity_snapshots::{ContentEntityType, EntityKey, EntityRef},
9    prelude::*,
10};
11
12/// A rule publishing relies on that a course state breaks. Pages, chapters and modules are named
13/// as the state has them.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
15#[serde(tag = "kind", rename_all = "snake_case")]
16pub enum PublishBlocker {
17    /// The page is in a chapter the course doesn't have.
18    PageInMissingChapter { page: EntityRef, chapter_id: Uuid },
19    /// The pages share a path, which must be unique in the course.
20    DuplicatePath {
21        url_path: String,
22        pages: Vec<EntityRef>,
23    },
24    /// The pages share a position in their chapter, or among the top-level pages with no
25    /// `chapter`.
26    DuplicatePageOrderNumber {
27        chapter: Option<EntityRef>,
28        order_number: i32,
29        pages: Vec<EntityRef>,
30    },
31    /// The chapter is in a module the course doesn't have.
32    ChapterInMissingModule { chapter: EntityRef, module_id: Uuid },
33    /// The chapter's front page is a page the course doesn't have.
34    MissingFrontPage {
35        chapter: EntityRef,
36        front_page_id: Uuid,
37    },
38    /// The chapter's front page is in another chapter.
39    FrontPageInOtherChapter {
40        chapter: EntityRef,
41        front_page: EntityRef,
42    },
43    /// The chapter's front page isn't at the path its chapter number gives it.
44    FrontPageOffPath {
45        chapter: EntityRef,
46        front_page: EntityRef,
47        url_path: String,
48        expected_url_path: String,
49    },
50    /// The chapters share a number, which must be unique in the course.
51    DuplicateChapterNumber {
52        chapter_number: i32,
53        chapters: Vec<EntityRef>,
54    },
55    /// The modules share an order number, which must be unique in the course.
56    DuplicateModuleOrderNumber {
57        order_number: i32,
58        modules: Vec<EntityRef>,
59    },
60}
61
62impl PublishBlocker {
63    /// The blocker with every label left empty.
64    fn unlabelled(mut self) -> Self {
65        let clear = |entity: &mut EntityRef| entity.label.clear();
66        match &mut self {
67            Self::PageInMissingChapter { page, .. } => clear(page),
68            Self::DuplicatePath { pages, .. } => pages.iter_mut().for_each(clear),
69            Self::DuplicatePageOrderNumber { chapter, pages, .. } => {
70                chapter.iter_mut().chain(pages.iter_mut()).for_each(clear)
71            }
72            Self::ChapterInMissingModule { chapter, .. }
73            | Self::MissingFrontPage { chapter, .. } => clear(chapter),
74            Self::FrontPageInOtherChapter {
75                chapter,
76                front_page,
77            }
78            | Self::FrontPageOffPath {
79                chapter,
80                front_page,
81                ..
82            } => {
83                clear(chapter);
84                clear(front_page);
85            }
86            Self::DuplicateChapterNumber { chapters, .. } => chapters.iter_mut().for_each(clear),
87            Self::DuplicateModuleOrderNumber { modules, .. } => modules.iter_mut().for_each(clear),
88        }
89        self
90    }
91}
92
93fn sorted(mut entities: Vec<EntityRef>) -> Vec<EntityRef> {
94    entities.sort_by_key(|entity| entity.entity_id);
95    entities
96}
97
98impl CourseState {
99    /// What publishing this state would break, in a fixed order; empty if nothing.
100    pub fn publish_blockers(&self) -> Vec<PublishBlocker> {
101        let mut blockers = Vec::new();
102        let chapter_ids: HashSet<Uuid> = self.chapters.iter().map(|c| c.id).collect();
103        let module_ids: HashSet<Uuid> = self.modules.iter().map(|m| m.id).collect();
104        let mut pages_by_path: BTreeMap<&str, Vec<EntityRef>> = BTreeMap::new();
105        let mut pages_by_position: BTreeMap<(Option<Uuid>, i32), Vec<EntityRef>> = BTreeMap::new();
106        for page in &self.pages {
107            let page_ref = page.entity_ref();
108            if let Some(chapter_id) = page.chapter_id
109                && !chapter_ids.contains(&chapter_id)
110            {
111                blockers.push(PublishBlocker::PageInMissingChapter {
112                    page: page_ref.clone(),
113                    chapter_id,
114                });
115            }
116            pages_by_path
117                .entry(&page.url_path)
118                .or_default()
119                .push(page_ref.clone());
120            pages_by_position
121                .entry((page.chapter_id, page.order_number))
122                .or_default()
123                .push(page_ref);
124        }
125        for (url_path, pages) in pages_by_path {
126            if pages.len() > 1 {
127                blockers.push(PublishBlocker::DuplicatePath {
128                    url_path: url_path.to_string(),
129                    pages: sorted(pages),
130                });
131            }
132        }
133        for ((chapter_id, order_number), pages) in pages_by_position {
134            if pages.len() > 1 {
135                blockers.push(PublishBlocker::DuplicatePageOrderNumber {
136                    chapter: chapter_id
137                        .map(|id| self.entity_ref(EntityKey::new(ContentEntityType::Chapter, id))),
138                    order_number,
139                    pages: sorted(pages),
140                });
141            }
142        }
143        let mut chapters_by_number: BTreeMap<i32, Vec<EntityRef>> = BTreeMap::new();
144        for chapter in &self.chapters {
145            let chapter_ref =
146                self.entity_ref(EntityKey::new(ContentEntityType::Chapter, chapter.id));
147            if !module_ids.contains(&chapter.course_module_id) {
148                blockers.push(PublishBlocker::ChapterInMissingModule {
149                    chapter: chapter_ref.clone(),
150                    module_id: chapter.course_module_id,
151                });
152            }
153            if let Some(front_page_id) = chapter.front_page_id {
154                let expected_url_path = chapters::front_page_path(chapter.chapter_number);
155                match self.page(front_page_id) {
156                    None => blockers.push(PublishBlocker::MissingFrontPage {
157                        chapter: chapter_ref.clone(),
158                        front_page_id,
159                    }),
160                    Some(page) if page.chapter_id != Some(chapter.id) => {
161                        blockers.push(PublishBlocker::FrontPageInOtherChapter {
162                            chapter: chapter_ref.clone(),
163                            front_page: page.entity_ref(),
164                        })
165                    }
166                    Some(page) if page.url_path != expected_url_path => {
167                        blockers.push(PublishBlocker::FrontPageOffPath {
168                            chapter: chapter_ref.clone(),
169                            front_page: page.entity_ref(),
170                            url_path: page.url_path.clone(),
171                            expected_url_path,
172                        })
173                    }
174                    Some(_) => {}
175                }
176            }
177            chapters_by_number
178                .entry(chapter.chapter_number)
179                .or_default()
180                .push(chapter_ref);
181        }
182        for (chapter_number, chapters) in chapters_by_number {
183            if chapters.len() > 1 {
184                blockers.push(PublishBlocker::DuplicateChapterNumber {
185                    chapter_number,
186                    chapters: sorted(chapters),
187                });
188            }
189        }
190        let mut modules_by_order_number: BTreeMap<i32, Vec<EntityRef>> = BTreeMap::new();
191        for module in &self.modules {
192            modules_by_order_number
193                .entry(module.order_number)
194                .or_default()
195                .push(self.entity_ref(EntityKey::new(ContentEntityType::Module, module.id)));
196        }
197        for (order_number, modules) in modules_by_order_number {
198            if modules.len() > 1 {
199                blockers.push(PublishBlocker::DuplicateModuleOrderNumber {
200                    order_number,
201                    modules: sorted(modules),
202                });
203            }
204        }
205        blockers
206    }
207
208    /// The publish blockers of this state that `before` doesn't have. The entities' labels don't
209    /// count, so a blocker that only renames them isn't new.
210    pub fn blockers_added_since(&self, before: &CourseState) -> Vec<PublishBlocker> {
211        let existing: Vec<PublishBlocker> = before
212            .publish_blockers()
213            .into_iter()
214            .map(PublishBlocker::unlabelled)
215            .collect();
216        self.publish_blockers()
217            .into_iter()
218            .filter(|blocker| !existing.contains(&blocker.clone().unlabelled()))
219            .collect()
220    }
221}