Skip to main content

headless_lms_models/library/agent_changesets/preview/
page.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use headless_lms_utils::document_schema_processor::GutenbergBlock;
5use utoipa::ToSchema;
6
7use crate::{
8    agent_changeset_entities::{self, AgentChangesetEntity},
9    agent_changesets::AgentChangeset,
10    error::{TryToOptional, missing_model_error},
11    library::{
12        agent_changesets::course_state::{CourseParts, ExerciseReviewConfig, load_live_course},
13        entity_snapshots::{
14            self, ChapterSnapshot, ContentEntity, ContentEntityType, EntityKey, EntitySnapshot,
15            PageSnapshot,
16        },
17    },
18    prelude::*,
19};
20
21/// What the preview of a staged page, or of a published one, renders.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
23pub struct PagePreview {
24    pub course_name: String,
25    pub language_code: String,
26    /// The page as staged, or as live if the changeset does not change it.
27    pub page: PreviewPage,
28    /// Whether the page is its chapter's front page, as staged.
29    pub is_chapter_front_page: bool,
30    /// Whether the preview shows the published course rather than a changeset's staged state.
31    pub is_published_state: bool,
32    /// The course's references, with the changeset's staged changes applied if there is one.
33    pub references: Vec<PreviewReference>,
34    /// The pages the link switches between: the changeset's added or changed pages, or all pages
35    /// of the course for a published preview. Empty unless the link covers more than one page.
36    pub changed_pages: Vec<PreviewPageLink>,
37    /// The visible pages of the page's chapter after its front page; filled only for a chapter
38    /// front page when the link covers more than one page.
39    pub chapter_pages: Vec<PreviewPageLink>,
40}
41
42/// A page a preview can switch to.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
44pub struct PreviewPageLink {
45    pub id: Uuid,
46    pub title: String,
47    pub url_path: String,
48    pub order_number: i32,
49}
50
51/// A bibliography entry the page's citations refer to.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
53pub struct PreviewReference {
54    pub citation_key: String,
55    pub reference: String,
56}
57
58/// A page as students see it before answering anything. The preview needs no login, so exercise
59/// specs, model solutions and review settings other than the instructions are left out.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
61pub struct PreviewPage {
62    pub id: Uuid,
63    pub course_id: Uuid,
64    pub chapter_id: Option<Uuid>,
65    pub url_path: String,
66    pub title: String,
67    pub hidden: bool,
68    pub order_number: i32,
69    pub content: Vec<GutenbergBlock>,
70    pub exercises: Vec<PreviewExercise>,
71}
72
73/// An exercise of a previewed page.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
75pub struct PreviewExercise {
76    pub id: Uuid,
77    pub name: String,
78    /// The assignment blocks of its tasks, in slide and task order.
79    pub assignment: Vec<serde_json::Value>,
80    /// Whether students review answers to it, themselves or each other.
81    pub has_reviews: bool,
82    pub uses_course_default_review_config: bool,
83    /// The review instructions blocks students see while reviewing. For an exercise that uses the
84    /// course default, only a preview link fills them in.
85    pub review_instructions: Option<serde_json::Value>,
86}
87
88impl From<PageSnapshot> for PreviewPage {
89    fn from(page: PageSnapshot) -> Self {
90        let PageSnapshot {
91            id,
92            course_id,
93            chapter_id,
94            url_path,
95            title,
96            hidden,
97            order_number,
98            content,
99            exercises,
100            mut exercise_slides,
101            mut exercise_tasks,
102            joins_page_language_group_id: _,
103        } = page;
104        exercise_slides.sort_by_key(|slide| (slide.order_number, slide.id));
105        exercise_tasks.sort_by_key(|task| (task.order_number, task.id));
106        let mut assignments_by_slide: HashMap<Uuid, Vec<serde_json::Value>> = HashMap::new();
107        for task in exercise_tasks {
108            if let serde_json::Value::Array(blocks) = task.assignment {
109                assignments_by_slide
110                    .entry(task.exercise_slide_id)
111                    .or_default()
112                    .extend(blocks);
113            }
114        }
115        let exercises = exercises
116            .into_iter()
117            .map(|exercise| PreviewExercise {
118                assignment: exercise_slides
119                    .iter()
120                    .filter(|slide| slide.exercise_id == exercise.id)
121                    .flat_map(|slide| assignments_by_slide.remove(&slide.id).unwrap_or_default())
122                    .collect(),
123                id: exercise.id,
124                has_reviews: exercise.needs_peer_review || exercise.needs_self_review,
125                uses_course_default_review_config: exercise
126                    .use_course_default_peer_or_self_review_config,
127                review_instructions: ExerciseReviewConfig::of_exercise(&exercise)
128                    .own_review_instructions()
129                    .cloned(),
130                name: exercise.name,
131            })
132            .collect();
133        Self {
134            id,
135            course_id,
136            chapter_id,
137            url_path,
138            title,
139            hidden,
140            order_number,
141            content: Arc::unwrap_or_clone(content),
142            exercises,
143        }
144    }
145}
146
147fn find_row(rows: &[AgentChangesetEntity], key: EntityKey) -> Option<&AgentChangesetEntity> {
148    rows.iter()
149        .find(|row| row.entity_type == key.entity_type && row.entity_id == key.entity_id)
150}
151
152/// What a preview link of `page_id` in `changeset` renders. The caller checks that the changeset
153/// is the link's user's and that they may still edit its course. Errors with `NotFound` if the
154/// changeset is no longer open or if the page does not exist in it or live.
155pub async fn resolve_preview(
156    conn: &mut PgConnection,
157    changeset: &AgentChangeset,
158    page_id: Uuid,
159) -> ModelResult<PagePreview> {
160    ensure_open(changeset)?;
161    let rows = agent_changeset_entities::get_by_changeset_id(conn, changeset.id).await?;
162    render_page(conn, changeset.course_id, &rows, page_id).await
163}
164
165/// What a preview link of the published `page_id` of `course_id` renders. The caller checks that
166/// the link's user may still edit the course. Errors with `NotFound` if the page is not in the
167/// course.
168pub async fn resolve_published_preview(
169    conn: &mut PgConnection,
170    course_id: Uuid,
171    page_id: Uuid,
172) -> ModelResult<PagePreview> {
173    let mut preview = render_page(conn, course_id, &[], page_id).await?;
174    preview.is_published_state = true;
175    Ok(preview)
176}
177
178fn ensure_open(changeset: &AgentChangeset) -> ModelResult<()> {
179    if !changeset.is_open() {
180        return Err(model_err!(
181            NotFound,
182            "The changeset of this preview is no longer open".to_string()
183        ));
184    }
185    Ok(())
186}
187
188/// The page `page_id` as `staged_rows` leave it over the live course.
189async fn render_page(
190    conn: &mut PgConnection,
191    course_id: Uuid,
192    staged_rows: &[AgentChangesetEntity],
193    page_id: Uuid,
194) -> ModelResult<PagePreview> {
195    let staged = find_row(
196        staged_rows,
197        EntityKey::new(ContentEntityType::Page, page_id),
198    );
199    let page = match staged {
200        Some(entity) => match &entity.after_snapshot {
201            Some(EntitySnapshot::Page(page)) => page.clone(),
202            _ => {
203                return Err(model_err!(
204                    NotFound,
205                    "The changeset deletes this page".to_string()
206                ));
207            }
208        },
209        None => entity_snapshots::load_page(conn, page_id)
210            .await?
211            .ok_or_else(missing_model_error(
212                ModelErrorType::NotFound,
213                "Page not found",
214            ))?,
215    };
216    if page.course_id != course_id {
217        return Err(model_err!(NotFound, "Page not found".to_string()));
218    }
219    let mut page = PreviewPage::from(page);
220    if page
221        .exercises
222        .iter()
223        .any(|exercise| exercise.uses_course_default_review_config)
224        && let Some(live_default) =
225            entity_snapshots::load_course_default_review_config(conn, course_id).await?
226    {
227        let staged_default = find_row(
228            staged_rows,
229            EntityKey::new(
230                ContentEntityType::CourseDefaultReviewConfig,
231                live_default.id,
232            ),
233        )
234        .and_then(|row| match &row.after_snapshot {
235            Some(EntitySnapshot::CourseDefaultReviewConfig(config)) => Some(config.clone()),
236            _ => None,
237        })
238        .unwrap_or(live_default);
239        for exercise in page.exercises.iter_mut() {
240            if exercise.uses_course_default_review_config {
241                exercise.review_instructions = staged_default.review_instructions.clone();
242            }
243        }
244    }
245    let is_chapter_front_page = match page.chapter_id {
246        Some(chapter_id) => {
247            staged_front_page_id(conn, staged_rows, chapter_id).await? == Some(page.id)
248        }
249        None => false,
250    };
251    let course = crate::courses::get_course(conn, course_id).await?;
252    let live = load_live_course(conn, &course, CourseParts::Glossary).await?;
253    let references = live
254        .overlay(staged_rows, CourseParts::Glossary)
255        .references
256        .into_iter()
257        .map(|reference| PreviewReference {
258            citation_key: reference.citation_key,
259            reference: reference.reference,
260        })
261        .collect();
262    Ok(PagePreview {
263        course_name: course.name,
264        language_code: course.language_code,
265        page,
266        is_chapter_front_page,
267        is_published_state: false,
268        references,
269        changed_pages: Vec::new(),
270        chapter_pages: Vec::new(),
271    })
272}
273
274/// The pages `changeset` adds or changes, in the order the staged course structure shows them.
275pub async fn changed_page_links(
276    conn: &mut PgConnection,
277    changeset: &AgentChangeset,
278) -> ModelResult<Vec<PreviewPageLink>> {
279    let rows = agent_changeset_entities::get_by_changeset_id(conn, changeset.id).await?;
280    page_links(conn, changeset.course_id, &rows, true).await
281}
282
283/// The pages of `course_id` as `staged_rows` leave them, in course order: only those the rows add
284/// or change with `only_changed`, else all of them.
285async fn page_links(
286    conn: &mut PgConnection,
287    course_id: Uuid,
288    staged_rows: &[AgentChangesetEntity],
289    only_changed: bool,
290) -> ModelResult<Vec<PreviewPageLink>> {
291    let changed: HashSet<Uuid> = staged_rows
292        .iter()
293        .filter(|row| matches!(row.after_snapshot, Some(EntitySnapshot::Page(_))))
294        .map(|row| row.entity_id)
295        .collect();
296    if only_changed && changed.is_empty() {
297        return Ok(Vec::new());
298    }
299    let course = crate::courses::get_course(conn, course_id).await?;
300    let staged = load_live_course(conn, &course, CourseParts::All)
301        .await?
302        .overlay(staged_rows, CourseParts::All);
303    let chapters = staged
304        .modules_in_order()
305        .into_iter()
306        .flat_map(|module| staged.chapters_in_order(module.id))
307        .map(|chapter| Some(chapter.id));
308    Ok(std::iter::once(None)
309        .chain(chapters)
310        .flat_map(|chapter_id| staged.pages_in_order(chapter_id))
311        .filter(|page| !only_changed || changed.contains(&page.id))
312        .map(|page| PreviewPageLink {
313            id: page.id,
314            title: page.title.clone(),
315            url_path: page.url_path.clone(),
316            order_number: page.order_number,
317        })
318        .collect())
319}
320
321/// What the preview link of the whole `changeset` renders: `page_id`, or the first changed page
322/// without one, with the changed pages to switch between. The caller checks the same things as
323/// for [`resolve_preview`]. Errors with `NotFound` also if the changeset changes no pages and
324/// `page_id` is `None`.
325pub async fn resolve_changeset_preview(
326    conn: &mut PgConnection,
327    changeset: &AgentChangeset,
328    page_id: Option<Uuid>,
329) -> ModelResult<PagePreview> {
330    ensure_open(changeset)?;
331    let rows = agent_changeset_entities::get_by_changeset_id(conn, changeset.id).await?;
332    render_with_page_links(conn, changeset.course_id, &rows, true, page_id).await
333}
334
335/// What the preview link of the whole published course renders: `page_id`, or the first page
336/// without one, with all pages to switch between. The caller checks the same things as for
337/// [`resolve_published_preview`].
338pub async fn resolve_published_course_preview(
339    conn: &mut PgConnection,
340    course_id: Uuid,
341    page_id: Option<Uuid>,
342) -> ModelResult<PagePreview> {
343    let mut preview = render_with_page_links(conn, course_id, &[], false, page_id).await?;
344    preview.is_published_state = true;
345    Ok(preview)
346}
347
348async fn render_with_page_links(
349    conn: &mut PgConnection,
350    course_id: Uuid,
351    staged_rows: &[AgentChangesetEntity],
352    only_changed: bool,
353    page_id: Option<Uuid>,
354) -> ModelResult<PagePreview> {
355    let page_links = page_links(conn, course_id, staged_rows, only_changed).await?;
356    let page_id = page_id
357        .or_else(|| page_links.first().map(|link| link.id))
358        .ok_or_else(missing_model_error(
359            ModelErrorType::NotFound,
360            "The preview has no pages",
361        ))?;
362    let mut preview = render_page(conn, course_id, staged_rows, page_id).await?;
363    if let (true, Some(chapter_id)) = (preview.is_chapter_front_page, preview.page.chapter_id) {
364        let course = crate::courses::get_course(conn, course_id).await?;
365        let staged = load_live_course(conn, &course, CourseParts::All)
366            .await?
367            .overlay(staged_rows, CourseParts::All);
368        preview.chapter_pages = staged
369            .pages_in_order(Some(chapter_id))
370            .into_iter()
371            .filter(|page| page.id != page_id && !page.hidden)
372            .map(|page| PreviewPageLink {
373                id: page.id,
374                title: page.title.clone(),
375                url_path: page.url_path.clone(),
376                order_number: page.order_number,
377            })
378            .collect();
379    }
380    preview.changed_pages = page_links;
381    Ok(preview)
382}
383
384/// The front page of chapter `chapter_id` as `staged_rows` leave it.
385async fn staged_front_page_id(
386    conn: &mut PgConnection,
387    staged_rows: &[AgentChangesetEntity],
388    chapter_id: Uuid,
389) -> ModelResult<Option<Uuid>> {
390    if let Some(row) = find_row(
391        staged_rows,
392        EntityKey::new(ContentEntityType::Chapter, chapter_id),
393    ) {
394        return Ok(row
395            .after_snapshot
396            .as_ref()
397            .and_then(ChapterSnapshot::narrow)
398            .and_then(|chapter| chapter.front_page_id));
399    }
400    Ok(crate::chapters::get_chapter(conn, chapter_id)
401        .await
402        .optional()?
403        .and_then(|chapter| chapter.front_page_id))
404}