1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::sync::Arc;
3
4use super::{ExerciseDeadline, ExerciseRecord, ExerciseReviewConfig, PageRecord};
5use crate::{
6 agent_changeset_entities::AgentChangesetEntity,
7 chapters, course_modules,
8 courses::Course,
9 glossary,
10 library::entity_snapshots::{
11 self, ChapterSnapshot, ContentEntity, ContentEntityType, CourseDefaultReviewConfigSnapshot,
12 CourseSettingsSnapshot, EntityKey, EntityRef, EntitySnapshot, GlossaryTermSnapshot,
13 ModuleSnapshot, PageSnapshot, ReferenceSnapshot, match_snapshot, with_snapshot_type,
14 },
15 material_references,
16 prelude::*,
17};
18
19#[derive(Debug, Clone, PartialEq)]
21pub struct CourseState {
22 pub settings: CourseSettingsSnapshot,
23 pub modules: Vec<ModuleSnapshot>,
24 pub chapters: Vec<ChapterSnapshot>,
25 pub pages: Vec<PageRecord>,
26 pub glossary: Vec<GlossaryTermSnapshot>,
27 pub references: Vec<ReferenceSnapshot>,
28 pub course_default_review_config: Option<CourseDefaultReviewConfigSnapshot>,
30 pub exercises: Option<BTreeMap<Uuid, ExerciseRecord>>,
34 pub exercise_deadlines: BTreeMap<Uuid, ExerciseDeadline>,
36}
37
38#[derive(Debug, Clone, PartialEq)]
40pub enum StagedEntity {
41 Write(EntitySnapshot),
43 Delete(EntityKey),
44}
45
46impl StagedEntity {
47 pub fn key(&self) -> EntityKey {
49 match self {
50 Self::Write(snapshot) => snapshot.key(),
51 Self::Delete(key) => *key,
52 }
53 }
54
55 pub fn after(&self) -> Option<&EntitySnapshot> {
57 match self {
58 Self::Write(snapshot) => Some(snapshot),
59 Self::Delete(_) => None,
60 }
61 }
62
63 pub fn into_after(self) -> Option<EntitySnapshot> {
65 match self {
66 Self::Write(snapshot) => Some(snapshot),
67 Self::Delete(_) => None,
68 }
69 }
70
71 pub fn new(key: EntityKey, after: Option<EntitySnapshot>) -> Self {
73 match after {
74 Some(snapshot) => Self::Write(snapshot),
75 None => Self::Delete(key),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Default, PartialEq)]
83pub struct StateChanges {
84 pub entities: Vec<StagedEntity>,
85 pub pages: Vec<Uuid>,
86}
87
88fn changed_by_id<T: ContentEntity>(before: &[T], after: &[T], changes: &mut Vec<StagedEntity>) {
89 let before_by_id: HashMap<Uuid, &T> = before.iter().map(|b| (b.id(), b)).collect();
90 let after_ids: HashSet<Uuid> = after.iter().map(T::id).collect();
91 for item in after {
92 if before_by_id.get(&item.id()) != Some(&item) {
93 changes.push(StagedEntity::Write(item.clone().into()));
94 }
95 }
96 for item in before {
97 if !after_ids.contains(&item.id()) {
98 changes.push(StagedEntity::Delete(EntityKey::new(
99 T::ENTITY_TYPE,
100 item.id(),
101 )));
102 }
103 }
104}
105
106impl CourseState {
107 pub fn course_id(&self) -> Uuid {
109 self.settings.course_id
110 }
111
112 pub fn page(&self, page_id: Uuid) -> Option<&PageRecord> {
114 self.pages.iter().find(|p| p.id == page_id)
115 }
116
117 pub fn page_mut(&mut self, page_id: Uuid) -> Option<&mut PageRecord> {
119 self.pages.iter_mut().find(|p| p.id == page_id)
120 }
121
122 pub fn pages_in_order(&self, chapter_id: Option<Uuid>) -> Vec<&PageRecord> {
126 let first = match chapter_id {
127 Some(chapter_id) => self
128 .chapters
129 .iter()
130 .find(|c| c.id == chapter_id)
131 .and_then(|c| c.front_page_id),
132 None => self
133 .pages
134 .iter()
135 .find(|p| p.chapter_id.is_none() && p.url_path == "/")
136 .map(|p| p.id),
137 };
138 let mut pages: Vec<&PageRecord> = self
139 .pages
140 .iter()
141 .filter(|p| p.chapter_id == chapter_id)
142 .collect();
143 pages.sort_by(|a, b| {
144 (Some(a.id) != first, a.order_number, &a.url_path).cmp(&(
145 Some(b.id) != first,
146 b.order_number,
147 &b.url_path,
148 ))
149 });
150 pages
151 }
152
153 pub fn chapters_in_order(&self, module_id: Uuid) -> Vec<&ChapterSnapshot> {
155 let mut chapters: Vec<&ChapterSnapshot> = self
156 .chapters
157 .iter()
158 .filter(|c| c.course_module_id == module_id)
159 .collect();
160 chapters.sort_by_key(|c| (c.chapter_number, c.id));
161 chapters
162 }
163
164 pub fn modules_in_order(&self) -> Vec<&ModuleSnapshot> {
166 let mut modules: Vec<&ModuleSnapshot> = self.modules.iter().collect();
167 modules.sort_by_key(|m| (m.name.is_some(), m.order_number, m.id));
168 modules
169 }
170
171 pub fn has_entity(
173 &self,
174 EntityKey {
175 entity_type,
176 entity_id,
177 }: EntityKey,
178 ) -> bool {
179 with_snapshot_type!(entity_type, T => T::is_in(self, entity_id))
180 }
181
182 pub fn entity_snapshot(
184 &self,
185 EntityKey {
186 entity_type,
187 entity_id,
188 }: EntityKey,
189 ) -> Option<EntitySnapshot> {
190 with_snapshot_type!(entity_type, T => {
191 T::find_in(self, entity_id).cloned().map(EntitySnapshot::from)
192 })
193 }
194
195 pub fn entity_label(&self, key: EntityKey) -> Option<String> {
197 match key.entity_type {
198 ContentEntityType::Page => self.page(key.entity_id).map(|page| page.title.clone()),
199 entity_type => with_snapshot_type!(entity_type, T => {
200 T::find_in(self, key.entity_id).map(|entity| entity.label().to_string())
201 }),
202 }
203 }
204
205 pub fn set_entity(
207 &mut self,
208 EntityKey {
209 entity_type,
210 entity_id,
211 }: EntityKey,
212 snapshot: Option<&EntitySnapshot>,
213 ) {
214 match snapshot {
215 Some(snapshot) => match_snapshot!(snapshot, snapshot => snapshot.put_in(self)),
216 None => with_snapshot_type!(entity_type, T => T::remove_from(self, entity_id)),
217 }
218 }
219
220 pub fn copy_entity_from(&mut self, source: &CourseState, key: EntityKey) {
222 let EntityKey {
223 entity_type,
224 entity_id,
225 } = key;
226 if entity_type != ContentEntityType::Page {
227 self.set_entity(key, source.entity_snapshot(key).as_ref());
228 return;
229 }
230 match source.page(entity_id) {
231 Some(page) => {
232 for exercise_id in page.content.iter().filter_map(|b| b.exercise_id()) {
233 if let Some(deadline) = source.exercise_deadlines.get(&exercise_id) {
234 self.exercise_deadlines.insert(exercise_id, *deadline);
235 }
236 }
237 put_by_id(&mut self.pages, page.clone(), |p| p.id)
238 }
239 None => self.pages.retain(|p| p.id != entity_id),
240 }
241 if let (Some(exercises), Some(source_exercises)) =
242 (self.exercises.as_mut(), source.exercises.as_ref())
243 {
244 exercises.retain(|_, exercise| exercise.page_id != entity_id);
245 exercises.extend(
246 source_exercises
247 .iter()
248 .filter(|(_, exercise)| exercise.page_id == entity_id)
249 .map(|(id, exercise)| (*id, exercise.clone())),
250 );
251 }
252 }
253
254 pub fn set_page_exercises(&mut self, page_id: Uuid, page: Option<&PageSnapshot>) {
257 let Some(exercises) = self.exercises.as_mut() else {
258 return;
259 };
260 exercises.retain(|_, exercise| exercise.page_id != page_id);
261 for exercise in page.map(ExerciseRecord::of_page).unwrap_or_default() {
262 exercises.insert(exercise.id, exercise);
263 }
264 }
265
266 pub fn set_page_exercise_deadlines(&mut self, page: &PageSnapshot) {
268 for exercise in &page.exercises {
269 self.exercise_deadlines.insert(
270 exercise.id,
271 ExerciseDeadline {
272 own: exercise.deadline,
273 chapter_id: page.chapter_id,
274 },
275 );
276 }
277 }
278
279 pub fn set_exercise_deadline(&mut self, exercise_id: Uuid, deadline: Option<DateTime<Utc>>) {
281 if let Some(record) = self
282 .exercises
283 .as_mut()
284 .and_then(|exercises| exercises.get_mut(&exercise_id))
285 {
286 record.deadline = deadline;
287 }
288 if let Some(entry) = self.exercise_deadlines.get_mut(&exercise_id) {
289 entry.own = deadline;
290 }
291 }
292
293 pub fn fork_review_config(&mut self, exercise_id: Uuid, config_id: Uuid) {
296 let review_instructions = self
297 .course_default_review_config
298 .as_ref()
299 .and_then(|config| config.review_instructions.clone())
300 .map(Arc::new);
301 if let Some(record) = self.exercise_mut(exercise_id) {
302 record.review_config = ExerciseReviewConfig::Own {
303 config_id,
304 review_instructions,
305 };
306 }
307 }
308
309 pub fn use_course_default_review_config(&mut self, exercise_id: Uuid) {
312 if let Some(record) = self.exercise_mut(exercise_id) {
313 record.review_config = ExerciseReviewConfig::CourseDefault;
314 }
315 }
316
317 pub fn exercise_mut(&mut self, exercise_id: Uuid) -> Option<&mut ExerciseRecord> {
319 self.exercises.as_mut()?.get_mut(&exercise_id)
320 }
321
322 pub fn effective_exercise_deadline(&self, exercise_id: Uuid) -> Option<DateTime<Utc>> {
325 let entry = self.exercise_deadlines.get(&exercise_id)?;
326 entry.own.or_else(|| {
327 let chapter_id = entry.chapter_id?;
328 self.chapters.iter().find(|c| c.id == chapter_id)?.deadline
329 })
330 }
331
332 pub fn exercise(&self, exercise_id: Uuid) -> Option<&ExerciseRecord> {
334 self.exercises.as_ref()?.get(&exercise_id)
335 }
336
337 pub fn exercise_page_id(&self, exercise_id: Uuid) -> Option<Uuid> {
340 self.pages
341 .iter()
342 .find(|page| {
343 page.content
344 .iter()
345 .any(|block| block.exercise_id() == Some(exercise_id))
346 })
347 .map(|page| page.id)
348 .or_else(|| self.exercise(exercise_id).map(|exercise| exercise.page_id))
349 }
350
351 pub fn exercise_page_ids(&self) -> HashMap<Uuid, Uuid> {
353 self.pages
354 .iter()
355 .flat_map(|page| {
356 page.content
357 .iter()
358 .filter_map(|block| block.exercise_id())
359 .map(|exercise_id| (exercise_id, page.id))
360 })
361 .collect()
362 }
363
364 pub fn overlay<'a>(
367 &self,
368 rows: impl IntoIterator<Item = &'a AgentChangesetEntity>,
369 parts: CourseParts,
370 ) -> CourseState {
371 let mut staged = self.clone();
372 for row in rows {
373 if parts.includes(row.entity_type) {
374 staged.set_entity(row.key(), row.after_snapshot.as_ref());
375 }
376 }
377 staged
378 }
379
380 pub fn changes_since(&self, before: &CourseState) -> StateChanges {
383 let mut entities = Vec::new();
384 if before.settings != self.settings {
385 entities.push(StagedEntity::Write(self.settings.clone().into()));
386 }
387 changed_by_id(&before.modules, &self.modules, &mut entities);
388 changed_by_id(&before.chapters, &self.chapters, &mut entities);
389 changed_by_id(&before.glossary, &self.glossary, &mut entities);
390 changed_by_id(&before.references, &self.references, &mut entities);
391 changed_by_id(
392 before.course_default_review_config.as_slice(),
393 self.course_default_review_config.as_slice(),
394 &mut entities,
395 );
396 let page_ids: HashSet<Uuid> = self.pages.iter().map(|p| p.id).collect();
397 let mut pages: Vec<Uuid> = self
398 .pages_changed_since(before)
399 .map(|p| p.id)
400 .chain(
401 before
402 .pages
403 .iter()
404 .filter(|p| !page_ids.contains(&p.id))
405 .map(|p| p.id),
406 )
407 .collect();
408 let exercise_pages = self.exercise_page_ids();
409 for exercise_id in self.exercises_changed_since(before) {
410 let owner = exercise_pages
411 .get(&exercise_id)
412 .copied()
413 .or_else(|| self.exercise(exercise_id).map(|e| e.page_id));
414 if let Some(owner) = owner
415 && !pages.contains(&owner)
416 {
417 pages.push(owner);
418 }
419 }
420 StateChanges { entities, pages }
421 }
422
423 pub fn exercises_changed_since(&self, before: &CourseState) -> Vec<Uuid> {
426 let (Some(after), Some(before)) = (&self.exercises, &before.exercises) else {
427 return Vec::new();
428 };
429 after
430 .iter()
431 .filter(|(id, exercise)| {
432 before
433 .get(id)
434 .is_some_and(|before| !exercise.has_same_content(before))
435 })
436 .map(|(id, _)| *id)
437 .collect()
438 }
439
440 pub fn pages_changed_since<'a>(
442 &'a self,
443 before: &CourseState,
444 ) -> impl Iterator<Item = &'a PageRecord> {
445 let before_by_id: HashMap<Uuid, &PageRecord> =
446 before.pages.iter().map(|p| (p.id, p)).collect();
447 self.pages.iter().filter(move |page| {
448 before_by_id
449 .get(&page.id)
450 .is_none_or(|before| before != page)
451 })
452 }
453
454 pub fn entity_ref(&self, key: EntityKey) -> EntityRef {
456 EntityRef {
457 entity_type: key.entity_type,
458 entity_id: key.entity_id,
459 label: self.entity_label(key).unwrap_or_default(),
460 }
461 }
462
463 pub fn is_chapter_front_page(&self, page_id: Uuid) -> bool {
465 self.chapters
466 .iter()
467 .any(|chapter| chapter.front_page_id == Some(page_id))
468 }
469
470 pub fn is_live(&self) -> bool {
472 !self.settings.is_draft && !self.settings.is_test_mode
473 }
474}
475
476pub(crate) trait InCourseState: ContentEntity {
478 fn find_in(state: &CourseState, id: Uuid) -> Option<&Self>;
480
481 fn is_in(state: &CourseState, id: Uuid) -> bool {
482 Self::find_in(state, id).is_some()
483 }
484
485 fn put_in(&self, state: &mut CourseState);
487
488 fn remove_from(state: &mut CourseState, id: Uuid);
490}
491
492fn put_by_id<T>(items: &mut Vec<T>, item: T, id_of: impl Fn(&T) -> Uuid) {
494 match items
495 .iter_mut()
496 .find(|existing| id_of(existing) == id_of(&item))
497 {
498 Some(existing) => *existing = item,
499 None => items.push(item),
500 }
501}
502
503macro_rules! listed_in_state {
505 ($snapshot:ty, $list:ident) => {
506 impl InCourseState for $snapshot {
507 fn find_in(state: &CourseState, id: Uuid) -> Option<&Self> {
508 state.$list.iter().find(|item| item.id == id)
509 }
510
511 fn put_in(&self, state: &mut CourseState) {
512 put_by_id(&mut state.$list, self.clone(), |item| item.id)
513 }
514
515 fn remove_from(state: &mut CourseState, id: Uuid) {
516 state.$list.retain(|item| item.id != id)
517 }
518 }
519 };
520}
521
522listed_in_state!(ChapterSnapshot, chapters);
523listed_in_state!(ModuleSnapshot, modules);
524listed_in_state!(GlossaryTermSnapshot, glossary);
525listed_in_state!(ReferenceSnapshot, references);
526
527impl InCourseState for PageSnapshot {
528 fn find_in(_state: &CourseState, _id: Uuid) -> Option<&Self> {
529 None
530 }
531
532 fn is_in(state: &CourseState, id: Uuid) -> bool {
533 state.page(id).is_some()
534 }
535
536 fn put_in(&self, state: &mut CourseState) {
537 put_by_id(&mut state.pages, PageRecord::from(self), |page| page.id);
538 state.set_page_exercises(self.id, Some(self));
539 state.set_page_exercise_deadlines(self);
540 }
541
542 fn remove_from(state: &mut CourseState, id: Uuid) {
543 state.pages.retain(|page| page.id != id);
544 state.set_page_exercises(id, None);
545 }
546}
547
548impl InCourseState for CourseSettingsSnapshot {
549 fn find_in(state: &CourseState, id: Uuid) -> Option<&Self> {
550 (state.settings.course_id == id).then_some(&state.settings)
551 }
552
553 fn put_in(&self, state: &mut CourseState) {
554 state.settings = self.clone();
555 }
556
557 fn remove_from(_state: &mut CourseState, _id: Uuid) {}
558}
559
560impl InCourseState for CourseDefaultReviewConfigSnapshot {
561 fn find_in(state: &CourseState, id: Uuid) -> Option<&Self> {
562 state
563 .course_default_review_config
564 .as_ref()
565 .filter(|config| config.id == id)
566 }
567
568 fn put_in(&self, state: &mut CourseState) {
569 state.course_default_review_config = Some(self.clone());
570 }
571
572 fn remove_from(state: &mut CourseState, id: Uuid) {
573 if Self::find_in(state, id).is_some() {
574 state.course_default_review_config = None;
575 }
576 }
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
582pub enum CourseParts {
583 All,
584 Settings,
586 Glossary,
587}
588
589impl CourseParts {
590 fn includes(self, entity_type: ContentEntityType) -> bool {
591 match self {
592 CourseParts::All => true,
593 CourseParts::Settings => matches!(
594 entity_type,
595 ContentEntityType::CourseSettings | ContentEntityType::Module
596 ),
597 CourseParts::Glossary => matches!(
598 entity_type,
599 ContentEntityType::CourseSettings
600 | ContentEntityType::GlossaryTerm
601 | ContentEntityType::Reference
602 ),
603 }
604 }
605}
606
607pub async fn load_live_course(
609 conn: &mut PgConnection,
610 course: &Course,
611 parts: CourseParts,
612) -> ModelResult<CourseState> {
613 let mut state = CourseState {
614 settings: entity_snapshots::course_settings_of(conn, course).await?,
615 modules: Vec::new(),
616 chapters: Vec::new(),
617 pages: Vec::new(),
618 glossary: Vec::new(),
619 references: Vec::new(),
620 course_default_review_config: None,
621 exercises: None,
622 exercise_deadlines: BTreeMap::new(),
623 };
624 if parts.includes(ContentEntityType::CourseDefaultReviewConfig) {
625 state.course_default_review_config =
626 entity_snapshots::load_course_default_review_config(conn, course.id).await?;
627 }
628 if parts.includes(ContentEntityType::Module) {
629 state.modules = course_modules::get_by_course_id(conn, course.id)
630 .await?
631 .into_iter()
632 .map(ModuleSnapshot::from)
633 .collect();
634 }
635 if parts.includes(ContentEntityType::Chapter) {
636 state.chapters = chapters::get_course_chapters(conn, course.id)
637 .await?
638 .into_iter()
639 .map(ChapterSnapshot::from)
640 .collect();
641 }
642 if parts.includes(ContentEntityType::Page) {
643 state.pages = crate::pages::get_all_by_course_id_and_visibility(
644 conn,
645 course.id,
646 crate::pages::PageVisibility::Any,
647 )
648 .await?
649 .into_iter()
650 .map(|page| {
651 Ok(PageRecord {
652 id: page.id,
653 chapter_id: page.chapter_id,
654 url_path: page.url_path,
655 title: page.title,
656 hidden: page.hidden,
657 order_number: page.order_number,
658 content: Arc::new(serde_json::from_value(page.content)?),
659 joins_page_language_group_id: None,
660 })
661 })
662 .collect::<ModelResult<_>>()?;
663 state.exercise_deadlines = crate::exercises::get_exercises_by_course_id(conn, course.id)
664 .await?
665 .into_iter()
666 .map(|exercise| {
667 let deadline = ExerciseDeadline {
668 own: exercise.deadline,
669 chapter_id: exercise.chapter_id,
670 };
671 (exercise.id, deadline)
672 })
673 .collect();
674 }
675 if parts.includes(ContentEntityType::GlossaryTerm) {
676 state.glossary = glossary::fetch_for_course(conn, course.id)
677 .await?
678 .into_iter()
679 .map(GlossaryTermSnapshot::from)
680 .collect();
681 state.references = material_references::get_references_by_course_id(conn, course.id)
682 .await?
683 .into_iter()
684 .map(ReferenceSnapshot::from)
685 .collect();
686 }
687 Ok(state)
688}