Skip to main content

headless_lms_models/library/entity_snapshots/
content_entity.rs

1use std::collections::HashMap;
2
3use super::{
4    ChapterSnapshot, ContentEntityType, CourseDefaultReviewConfigSnapshot, CourseSettingsSnapshot,
5    EntityKey, EntitySnapshot, GlossaryTermSnapshot, ModuleSnapshot, PageSnapshot,
6    ReferenceSnapshot, course_settings_of, load_pages,
7};
8use crate::{course_modules, prelude::*};
9
10/// What models know about one kind of content entity, implemented by its snapshot type, so that
11/// code over every kind dispatches on [`ContentEntityType`] once.
12pub(crate) trait ContentEntity: Clone + PartialEq + Into<EntitySnapshot> {
13    const ENTITY_TYPE: ContentEntityType;
14
15    /// The entity's id; the course id for course settings.
16    fn id(&self) -> Uuid;
17
18    fn course_id(&self) -> Uuid;
19
20    /// See [`EntityRef::label`](super::EntityRef::label).
21    fn label(&self) -> &str;
22
23    /// The chapter or module the entity is placed in; `None` for entities of the course itself.
24    fn parent(&self) -> Option<EntityKey> {
25        None
26    }
27
28    /// The snapshot, if it is of this kind.
29    fn narrow(snapshot: &EntitySnapshot) -> Option<&Self>;
30
31    /// The live entities with the ids, as [`load_live_many`](super::load_live_many) has them, by id.
32    async fn load_live(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<HashMap<Uuid, Self>>;
33
34    /// Locks the entities' rows until the transaction ends. Soft-deleted rows are locked too: a
35    /// publish can restore them. `ids` must be sorted, so that concurrent lockers take the rows in
36    /// the same order.
37    async fn lock_rows(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()>;
38}
39
40impl ContentEntity for PageSnapshot {
41    const ENTITY_TYPE: ContentEntityType = ContentEntityType::Page;
42
43    fn id(&self) -> Uuid {
44        self.id
45    }
46
47    fn course_id(&self) -> Uuid {
48        self.course_id
49    }
50
51    fn label(&self) -> &str {
52        &self.title
53    }
54
55    fn parent(&self) -> Option<EntityKey> {
56        self.chapter_id
57            .map(|chapter_id| EntityKey::new(ContentEntityType::Chapter, chapter_id))
58    }
59
60    fn narrow(snapshot: &EntitySnapshot) -> Option<&Self> {
61        match snapshot {
62            EntitySnapshot::Page(page) => Some(page),
63            _ => None,
64        }
65    }
66
67    async fn load_live(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<HashMap<Uuid, Self>> {
68        load_pages(conn, ids).await
69    }
70
71    async fn lock_rows(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
72        if !ids.is_empty() {
73            sqlx::query!(
74                "SELECT id FROM pages WHERE id = ANY($1) ORDER BY id FOR UPDATE",
75                ids
76            )
77            .fetch_all(conn)
78            .await?;
79        }
80        Ok(())
81    }
82}
83
84impl ContentEntity for ChapterSnapshot {
85    const ENTITY_TYPE: ContentEntityType = ContentEntityType::Chapter;
86
87    fn id(&self) -> Uuid {
88        self.id
89    }
90
91    fn course_id(&self) -> Uuid {
92        self.course_id
93    }
94
95    fn label(&self) -> &str {
96        &self.name
97    }
98
99    fn parent(&self) -> Option<EntityKey> {
100        Some(EntityKey::new(
101            ContentEntityType::Module,
102            self.course_module_id,
103        ))
104    }
105
106    fn narrow(snapshot: &EntitySnapshot) -> Option<&Self> {
107        match snapshot {
108            EntitySnapshot::Chapter(chapter) => Some(chapter),
109            _ => None,
110        }
111    }
112
113    async fn load_live(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<HashMap<Uuid, Self>> {
114        if ids.is_empty() {
115            return Ok(HashMap::new());
116        }
117        let chapters = crate::chapters::get_by_ids(conn, ids).await?;
118        Ok(chapters
119            .into_iter()
120            .map(|chapter| (chapter.id, ChapterSnapshot::from(chapter)))
121            .collect())
122    }
123
124    async fn lock_rows(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
125        if !ids.is_empty() {
126            sqlx::query!(
127                "SELECT id FROM chapters WHERE id = ANY($1) ORDER BY id FOR UPDATE",
128                ids
129            )
130            .fetch_all(conn)
131            .await?;
132        }
133        Ok(())
134    }
135}
136
137impl ContentEntity for ModuleSnapshot {
138    const ENTITY_TYPE: ContentEntityType = ContentEntityType::Module;
139
140    fn id(&self) -> Uuid {
141        self.id
142    }
143
144    fn course_id(&self) -> Uuid {
145        self.course_id
146    }
147
148    fn label(&self) -> &str {
149        self.name.as_deref().unwrap_or_default()
150    }
151
152    fn narrow(snapshot: &EntitySnapshot) -> Option<&Self> {
153        match snapshot {
154            EntitySnapshot::Module(module) => Some(module),
155            _ => None,
156        }
157    }
158
159    async fn load_live(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<HashMap<Uuid, Self>> {
160        if ids.is_empty() {
161            return Ok(HashMap::new());
162        }
163        Ok(course_modules::get_by_ids(conn, ids)
164            .await?
165            .into_iter()
166            .map(|module| (module.id, module.into()))
167            .collect())
168    }
169
170    async fn lock_rows(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
171        if !ids.is_empty() {
172            sqlx::query!(
173                "SELECT id FROM course_modules WHERE id = ANY($1) ORDER BY id FOR UPDATE",
174                ids
175            )
176            .fetch_all(conn)
177            .await?;
178        }
179        Ok(())
180    }
181}
182
183impl ContentEntity for CourseSettingsSnapshot {
184    const ENTITY_TYPE: ContentEntityType = ContentEntityType::CourseSettings;
185
186    fn id(&self) -> Uuid {
187        self.course_id
188    }
189
190    fn course_id(&self) -> Uuid {
191        self.course_id
192    }
193
194    fn label(&self) -> &str {
195        &self.name
196    }
197
198    fn narrow(snapshot: &EntitySnapshot) -> Option<&Self> {
199        match snapshot {
200            EntitySnapshot::CourseSettings(settings) => Some(settings),
201            _ => None,
202        }
203    }
204
205    async fn load_live(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<HashMap<Uuid, Self>> {
206        let mut settings = HashMap::with_capacity(ids.len());
207        for course in crate::courses::get_by_ids(conn, ids).await? {
208            settings.insert(course.id, course_settings_of(conn, &course).await?);
209        }
210        Ok(settings)
211    }
212
213    async fn lock_rows(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
214        if !ids.is_empty() {
215            sqlx::query!(
216                "SELECT id FROM courses WHERE id = ANY($1) ORDER BY id FOR UPDATE",
217                ids
218            )
219            .fetch_all(conn)
220            .await?;
221        }
222        Ok(())
223    }
224}
225
226impl ContentEntity for GlossaryTermSnapshot {
227    const ENTITY_TYPE: ContentEntityType = ContentEntityType::GlossaryTerm;
228
229    fn id(&self) -> Uuid {
230        self.id
231    }
232
233    fn course_id(&self) -> Uuid {
234        self.course_id
235    }
236
237    fn label(&self) -> &str {
238        &self.term
239    }
240
241    fn narrow(snapshot: &EntitySnapshot) -> Option<&Self> {
242        match snapshot {
243            EntitySnapshot::GlossaryTerm(term) => Some(term),
244            _ => None,
245        }
246    }
247
248    async fn load_live(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<HashMap<Uuid, Self>> {
249        if ids.is_empty() {
250            return Ok(HashMap::new());
251        }
252        let terms = crate::glossary::get_by_ids(conn, ids).await?;
253        Ok(terms
254            .into_iter()
255            .map(|term| (term.id, GlossaryTermSnapshot::from(term)))
256            .collect())
257    }
258
259    async fn lock_rows(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
260        if !ids.is_empty() {
261            sqlx::query!(
262                "SELECT id FROM glossary WHERE id = ANY($1) ORDER BY id FOR UPDATE",
263                ids
264            )
265            .fetch_all(conn)
266            .await?;
267        }
268        Ok(())
269    }
270}
271
272impl ContentEntity for ReferenceSnapshot {
273    const ENTITY_TYPE: ContentEntityType = ContentEntityType::Reference;
274
275    fn id(&self) -> Uuid {
276        self.id
277    }
278
279    fn course_id(&self) -> Uuid {
280        self.course_id
281    }
282
283    fn label(&self) -> &str {
284        &self.citation_key
285    }
286
287    fn narrow(snapshot: &EntitySnapshot) -> Option<&Self> {
288        match snapshot {
289            EntitySnapshot::Reference(reference) => Some(reference),
290            _ => None,
291        }
292    }
293
294    async fn load_live(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<HashMap<Uuid, Self>> {
295        if ids.is_empty() {
296            return Ok(HashMap::new());
297        }
298        let references = crate::material_references::get_by_ids(conn, ids).await?;
299        Ok(references
300            .into_iter()
301            .map(|reference| (reference.id, ReferenceSnapshot::from(reference)))
302            .collect())
303    }
304
305    async fn lock_rows(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
306        if !ids.is_empty() {
307            sqlx::query!(
308                "SELECT id FROM material_references WHERE id = ANY($1) ORDER BY id FOR UPDATE",
309                ids
310            )
311            .fetch_all(conn)
312            .await?;
313        }
314        Ok(())
315    }
316}
317
318impl ContentEntity for CourseDefaultReviewConfigSnapshot {
319    const ENTITY_TYPE: ContentEntityType = ContentEntityType::CourseDefaultReviewConfig;
320
321    fn id(&self) -> Uuid {
322        self.id
323    }
324
325    fn course_id(&self) -> Uuid {
326        self.course_id
327    }
328
329    fn label(&self) -> &str {
330        ""
331    }
332
333    fn narrow(snapshot: &EntitySnapshot) -> Option<&Self> {
334        match snapshot {
335            EntitySnapshot::CourseDefaultReviewConfig(config) => Some(config),
336            _ => None,
337        }
338    }
339
340    async fn load_live(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<HashMap<Uuid, Self>> {
341        if ids.is_empty() {
342            return Ok(HashMap::new());
343        }
344        let configs = crate::peer_or_self_review_configs::get_by_ids(conn, ids).await?;
345        Ok(configs
346            .into_iter()
347            .filter(|config| config.exercise_id.is_none())
348            .map(|config| (config.id, CourseDefaultReviewConfigSnapshot::from(config)))
349            .collect())
350    }
351
352    async fn lock_rows(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
353        if !ids.is_empty() {
354            sqlx::query!(
355                "SELECT id FROM peer_or_self_review_configs WHERE id = ANY($1) ORDER BY id FOR UPDATE",
356                ids
357            )
358            .fetch_all(conn)
359            .await?;
360        }
361        Ok(())
362    }
363}