Skip to main content

headless_lms_models/library/agent_changesets/course_state/
state.rs

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/// Every editable entity of one course.
20#[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    /// `None` when the course has none, or when the state was loaded without it.
29    pub course_default_review_config: Option<CourseDefaultReviewConfigSnapshot>,
30    /// By exercise id; `None` until [`CourseView::load_exercises`](super::CourseView::load_exercises)
31    /// or [`CourseView::load_exercises_on_pages`](super::CourseView::load_exercises_on_pages)
32    /// (only those pages') loads them.
33    pub exercises: Option<BTreeMap<Uuid, ExerciseRecord>>,
34    /// By exercise id; loaded with the pages. Entries of exercises no page holds any more stay.
35    pub exercise_deadlines: BTreeMap<Uuid, ExerciseDeadline>,
36}
37
38/// The state one entity has after an agent write call.
39#[derive(Debug, Clone, PartialEq)]
40pub enum StagedEntity {
41    /// The entity's full snapshot after the call. Ids of new entities are assigned by the caller.
42    Write(EntitySnapshot),
43    Delete(EntityKey),
44}
45
46impl StagedEntity {
47    /// The entity written or deleted.
48    pub fn key(&self) -> EntityKey {
49        match self {
50            Self::Write(snapshot) => snapshot.key(),
51            Self::Delete(key) => *key,
52        }
53    }
54
55    /// The snapshot after the call; `None` for a delete.
56    pub fn after(&self) -> Option<&EntitySnapshot> {
57        match self {
58            Self::Write(snapshot) => Some(snapshot),
59            Self::Delete(_) => None,
60        }
61    }
62
63    /// The snapshot after the call; `None` for a delete. See [`Self::after`].
64    pub fn into_after(self) -> Option<EntitySnapshot> {
65        match self {
66            Self::Write(snapshot) => Some(snapshot),
67            Self::Delete(_) => None,
68        }
69    }
70
71    /// Writes `after`, or deletes the entity when it is `None`.
72    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/// What changed between two course states. Pages are listed by id: their full snapshots hold
81/// exercises, which only the database has.
82#[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    /// The course the state belongs to.
108    pub fn course_id(&self) -> Uuid {
109        self.settings.course_id
110    }
111
112    /// The page `page_id`.
113    pub fn page(&self, page_id: Uuid) -> Option<&PageRecord> {
114        self.pages.iter().find(|p| p.id == page_id)
115    }
116
117    /// [`Self::page`] for editing.
118    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    /// The pages of a chapter, or the top-level pages with `None`, in the order the course
123    /// structure shows them: the chapter's front page (the course front page `/`) first, then by
124    /// order number and path.
125    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    /// The chapters of a module in the order the course structure shows them.
154    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    /// The modules in the order the course structure shows them: the default module first.
165    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    /// Whether the state has the entity.
172    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    /// The full snapshot of an entity, which a state holds of every entity type but pages.
183    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    /// What people call the entity; see [`entity_snapshots::EntityRef::label`].
196    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    /// Replaces, adds or (with `None`) removes one entity. Course settings can't be removed.
206    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    /// Sets one entity to its state in `source`, removing it if `source` doesn't have it.
221    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    /// Replaces the exercise records made from the page's snapshot with those of `page`, or
255    /// removes them with `None`. Does nothing to a state without exercise records.
256    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    /// Sets the deadlines of the page's exercises from its snapshot.
267    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    /// Sets the exercise's own deadline in its record, if loaded, and in its deadline entry.
280    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    /// Gives the exercise its own review config `config_id`, starting from a copy of the course
294    /// default's review instructions. No-op when the exercise's record isn't loaded.
295    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    /// Switches the exercise to the course default review config, dropping its own. No-op when
310    /// the exercise's record isn't loaded.
311    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    /// The exercise's record, to change; `None` also when the state has no exercise records.
318    pub fn exercise_mut(&mut self, exercise_id: Uuid) -> Option<&mut ExerciseRecord> {
319        self.exercises.as_mut()?.get_mut(&exercise_id)
320    }
321
322    /// The deadline that applies to the exercise: its own, else its chapter's. `None` also when
323    /// the state doesn't know the exercise.
324    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    /// The exercise's record; `None` also when the state has no exercise records.
333    pub fn exercise(&self, exercise_id: Uuid) -> Option<&ExerciseRecord> {
334        self.exercises.as_ref()?.get(&exercise_id)
335    }
336
337    /// The page holding the exercise: the one whose blocks hold it, else the one its record was
338    /// made from.
339    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    /// The page whose blocks hold each exercise, by exercise id.
352    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    /// The state with the staged entities of a changeset laid over it. Rows of entity types left
365    /// out of `parts` are skipped.
366    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    /// The entities that differ between `before` and this state; the inverse of
381    /// [`CourseState::overlay`].
382    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    /// The exercises whose records differ from those of `before`, when both have records.
424    /// Exercises only one of the states has are left out: their pages' blocks differ.
425    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    /// The pages of this state that differ from their state in `before`, new pages included.
441    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    /// The entity, labelled as this state has it; with an empty label if the state lacks it.
455    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    /// Whether the page is its chapter's front page in this state.
464    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    /// Students see this course: it is neither a draft nor in test mode.
471    pub fn is_live(&self) -> bool {
472        !self.settings.is_draft && !self.settings.is_test_mode
473    }
474}
475
476/// How a [`CourseState`] holds one kind of content entity.
477pub(crate) trait InCourseState: ContentEntity {
478    /// The entity in `state`. Always `None` for pages, which states hold as [`PageRecord`]s.
479    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    /// Adds the entity to `state`, or replaces it there.
486    fn put_in(&self, state: &mut CourseState);
487
488    /// Removes the entity from `state`; course settings stay, since a course always has them.
489    fn remove_from(state: &mut CourseState, id: Uuid);
490}
491
492/// Replaces the item with `item`'s id, or appends `item`.
493fn 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
503/// Implements [`InCourseState`] for a kind a [`CourseState`] holds a list of.
504macro_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/// Which parts of a course a tool reads; the course settings are always there, the other parts
580/// left out stay empty.
581#[derive(Debug, Clone, Copy, PartialEq, Eq)]
582pub enum CourseParts {
583    All,
584    /// The modules, whose settings the settings tools read too.
585    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
607/// Loads the parts of the course as published.
608pub 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}