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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
23pub struct PagePreview {
24 pub course_name: String,
25 pub language_code: String,
26 pub page: PreviewPage,
28 pub is_chapter_front_page: bool,
30 pub is_published_state: bool,
32 pub references: Vec<PreviewReference>,
34 pub changed_pages: Vec<PreviewPageLink>,
37 pub chapter_pages: Vec<PreviewPageLink>,
40}
41
42#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
53pub struct PreviewReference {
54 pub citation_key: String,
55 pub reference: String,
56}
57
58#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
75pub struct PreviewExercise {
76 pub id: Uuid,
77 pub name: String,
78 pub assignment: Vec<serde_json::Value>,
80 pub has_reviews: bool,
82 pub uses_course_default_review_config: bool,
83 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
152pub 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
165pub 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
188async 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
274pub 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
283async 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
321pub 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
335pub 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
384async 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}