Skip to main content

headless_lms_models/
pages.rs

1use std::collections::{HashMap, hash_map};
2
3use futures::future::{BoxFuture, OptionFuture};
4use headless_lms_utils::document_schema_processor::{
5    GutenbergBlock, contains_blocks_not_allowed_in_top_level_pages, filter_lock_chapter_blocks,
6    replace_duplicate_client_ids, suffix_duplicate_heading_anchors,
7};
8use itertools::Itertools;
9use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
10use serde_json::{Value, json};
11use sqlx::{AssertSqlSafe, Postgres, QueryBuilder, Row};
12use url::Url;
13use utoipa::ToSchema;
14
15use crate::error::{TryToOptional, ensure_version};
16use crate::{
17    CourseOrExamId, SpecFetcher,
18    chapters::{
19        self, Chapter, DatabaseChapter, get_chapter, get_chapter_by_page_id, get_course_chapters,
20    },
21    course_instances::{self, CourseInstance},
22    courses::{self, Course, CourseContextData, CourseMaterialCourse},
23    entity_history::HistoryAuthor,
24    exercise_service_info::{self, ExerciseServiceInfoApi},
25    exercise_services::{get_internal_public_spec_url, get_model_solution_url},
26    exercise_slides::ExerciseSlide,
27    exercise_tasks::ExerciseTask,
28    exercises::Exercise,
29    organizations::Organization,
30    page_history::{self, HistoryChangeReason, PageHistoryContent},
31    peer_or_self_review_configs::CmsPeerOrSelfReviewConfig,
32    peer_or_self_review_questions::{
33        CmsPeerOrSelfReviewQuestion, normalize_cms_peer_or_self_review_questions,
34    },
35    prelude::*,
36    url_redirections::CreatedRedirect,
37    user_chapter_locking_statuses,
38    user_course_settings::{self, UserCourseSettings},
39    user_exercise_states,
40};
41
42#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
43
44pub struct Page {
45    pub id: Uuid,
46    pub created_at: DateTime<Utc>,
47    pub updated_at: DateTime<Utc>,
48    pub course_id: Option<Uuid>,
49    pub exam_id: Option<Uuid>,
50    pub chapter_id: Option<Uuid>,
51    pub url_path: String,
52    pub title: String,
53    pub deleted_at: Option<DateTime<Utc>>,
54    // should always be a Vec<GutenbergBlock>, but is more convenient to keep as Value for sqlx
55    pub content: serde_json::Value,
56    pub order_number: i32,
57    pub copied_from: Option<Uuid>,
58    pub hidden: bool,
59    pub page_language_group_id: Option<Uuid>,
60}
61
62#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
63
64pub struct PageInfo {
65    pub page_id: Uuid,
66    pub page_title: String,
67    pub course_id: Option<Uuid>,
68    pub course_name: Option<String>,
69    pub course_slug: Option<String>,
70    pub organization_slug: Option<String>,
71}
72
73#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
74pub struct PageInfoSpecial {
75    pub page_id: Uuid,
76    pub url_path: String,
77    pub page_title: String,
78    pub order_number: i32,
79    pub chapter_title: Option<String>,
80    pub chapter_number: Option<i32>,
81    pub module_name: Option<String>,
82    pub module_number: Option<i32>,
83    pub content: serde_json::Value,
84}
85
86impl PageInfoSpecial {
87    pub fn blocks_cloned(&self) -> ModelResult<Vec<GutenbergBlock>> {
88        serde_json::from_value(self.content.clone()).map_err(Into::into)
89    }
90}
91
92#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
93
94pub struct PageAudioFiles {
95    pub id: Uuid,
96    pub page_id: Uuid,
97    pub created_at: DateTime<Utc>,
98    pub deleted_at: Option<DateTime<Utc>>,
99    pub path: String,
100    pub mime_type: String,
101}
102
103impl Page {
104    pub fn blocks_cloned(&self) -> ModelResult<Vec<GutenbergBlock>> {
105        serde_json::from_value(self.content.clone()).map_err(Into::into)
106    }
107}
108
109#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, ToSchema)]
110#[serde(rename_all = "snake_case")]
111pub enum LockChapterContentState {
112    NotLocked,
113    WaitingTeacherReview,
114    Visible,
115}
116
117#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
118
119pub struct CoursePageWithUserData {
120    pub page: Page,
121    pub instance: Option<CourseInstance>,
122    pub settings: Option<UserCourseSettings>,
123    pub course: Option<CourseMaterialCourse>,
124    pub organization: Option<Organization>,
125    pub lock_chapter_content_state: Option<LockChapterContentState>,
126    /// If true, the frontend needs to update the url in the browser to match the path in the page object without reloading the page.
127    pub was_redirected: bool,
128    pub is_test_mode: bool,
129}
130
131#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
132
133pub struct PageWithExercises {
134    #[serde(flatten)]
135    pub page: Page,
136    pub exercises: Vec<Exercise>,
137}
138
139#[derive(Default)]
140struct CourseMaterialPageContentFilterCache {
141    chapter_lock_content_states: HashMap<Uuid, LockChapterContentState>,
142    course_chapter_locking_enabled: HashMap<Uuid, bool>,
143}
144
145const URL_PATH_ENCODE_SET: &AsciiSet = &CONTROLS
146    .add(b' ')
147    .add(b'!')
148    .add(b'"')
149    .add(b'#')
150    .add(b'$')
151    .add(b'%')
152    .add(b'&')
153    .add(b'\'')
154    .add(b'(')
155    .add(b')')
156    .add(b'*')
157    .add(b'+')
158    .add(b',')
159    .add(b':')
160    .add(b';')
161    .add(b'<')
162    .add(b'=')
163    .add(b'>')
164    .add(b'?')
165    .add(b'@')
166    .add(b'[')
167    .add(b'\\')
168    .add(b']')
169    .add(b'^')
170    .add(b'`')
171    .add(b'{')
172    .add(b'|')
173    .add(b'}');
174
175/// Decode each maximal run of `%XX` escapes as one UTF-8 unit, dropping a run that isn't valid
176/// UTF-8 (rather than emitting U+FFFD). Non-escape characters pass through. Mirrors the migration's
177/// `safe_percent_decode`, so storage and lookup agree on inputs like `/%FF`.
178fn decode_percent_runs(input: &str) -> String {
179    let chars: Vec<char> = input.chars().collect();
180    let mut out = String::with_capacity(input.len());
181    let mut i = 0;
182    while i < chars.len() {
183        if percent_escape_byte_at(&chars, i).is_some() {
184            let mut bytes = Vec::new();
185            while let Some(byte) = percent_escape_byte_at(&chars, i) {
186                bytes.push(byte);
187                i += 3;
188            }
189            if let Ok(decoded) = std::str::from_utf8(&bytes) {
190                out.push_str(decoded);
191            }
192        } else {
193            out.push(chars[i]);
194            i += 1;
195        }
196    }
197    out
198}
199
200/// The byte a `%XX` escape at `chars[i]` decodes to, or `None` if there isn't one there.
201fn percent_escape_byte_at(chars: &[char], i: usize) -> Option<u8> {
202    if chars.get(i) != Some(&'%') {
203        return None;
204    }
205    let hi = chars.get(i + 1)?.to_digit(16)?;
206    let lo = chars.get(i + 2)?.to_digit(16)?;
207    Some((hi * 16 + lo) as u8)
208}
209
210/// Canonical storage/lookup form: decode `%xx`, then per `/`-segment turn whitespace into `-`,
211/// strip the unsafe ASCII in `URL_PATH_ENCODE_SET`, and collapse/trim dashes. Case, non-ASCII and
212/// `/`, `-`, `.`, `_`, `~` are kept. Unsafe chars are removed, not `%xx`-encoded, so every save
213/// funnels through here and paths can't re-accumulate escapes. Mirrors `clean_url_path` (page
214/// migration) and the frontend `cleanUrlPath`; keep the three in agreement.
215pub fn normalize_url_path_for_storage(url_path: &str) -> String {
216    decode_percent_runs(url_path)
217        .split('/')
218        .map(clean_url_path_segment)
219        .collect::<Vec<_>>()
220        .join("/")
221}
222
223/// Whether `ch` is an ASCII char that `URL_PATH_ENCODE_SET` would encode. The set's membership
224/// test is crate-private, so probe it via `utf8_percent_encode` (which encodes an ASCII char iff
225/// it's in the set). ASCII-only: the caller keeps non-ASCII verbatim.
226fn is_unsafe_ascii(ch: char) -> bool {
227    let mut buf = [0u8; 4];
228    let encoded = ch.encode_utf8(&mut buf);
229    utf8_percent_encode(encoded, URL_PATH_ENCODE_SET).next() != Some(encoded)
230}
231
232/// Cleans a single path segment (no `/`): whitespace runs become a single `-`, unsafe ASCII is
233/// dropped, runs of `-` collapse to one, and leading/trailing `-` are trimmed.
234fn clean_url_path_segment(segment: &str) -> String {
235    let mut out = String::with_capacity(segment.len());
236    for ch in segment.chars() {
237        // Whitespace becomes a dash; unsafe ASCII is dropped; everything else is kept.
238        let mapped = if ch.is_whitespace() {
239            Some('-')
240        } else if ch.is_ascii() && is_unsafe_ascii(ch) {
241            None
242        } else {
243            Some(ch)
244        };
245        match mapped {
246            // Collapse a run of dashes, whether from whitespace or a literal '-'.
247            Some('-') if out.ends_with('-') => {}
248            Some(c) => out.push(c),
249            None => {}
250        }
251    }
252    out.trim_matches('-').to_string()
253}
254
255/// Legacy lookup candidate: unsafe ASCII percent-encoded, non-ASCII kept decoded — the form used
256/// before paths were stored stripped. Lets pages written that way (notably exam pages, which the
257/// cleanup migration skips) still resolve.
258fn legacy_partially_encoded_url_path(url_path: &str) -> String {
259    let decoded = decode_percent_runs(url_path);
260    let trimmed = decoded.trim();
261    let mut normalized = String::with_capacity(trimmed.len());
262    let mut buf = [0u8; 4];
263    for ch in trimmed.chars() {
264        if ch.is_ascii() {
265            // For an ASCII char, utf8_percent_encode encodes it iff it is in the set.
266            normalized.extend(utf8_percent_encode(
267                ch.encode_utf8(&mut buf),
268                URL_PATH_ENCODE_SET,
269            ));
270        } else {
271            normalized.push(ch);
272        }
273    }
274    normalized
275}
276
277/// Legacy lookup candidate: the oldest form, with non-ASCII percent-encoded too. Lets pages stored
278/// that way still resolve.
279fn legacy_fully_encoded_url_path(url_path: &str) -> String {
280    let decoded = decode_percent_runs(url_path);
281    utf8_percent_encode(decoded.trim(), URL_PATH_ENCODE_SET).to_string()
282}
283
284/// Stored `url_path` forms to try for a requested path: the strip-canonical form plus the two
285/// legacy encoded forms, deduped (an ASCII-only safe path yields a single candidate).
286fn url_path_lookup_candidates(url_path: &str) -> Vec<String> {
287    let mut candidates = vec![normalize_url_path_for_storage(url_path)];
288    for form in [
289        legacy_partially_encoded_url_path(url_path),
290        legacy_fully_encoded_url_path(url_path),
291    ] {
292        if !candidates.contains(&form) {
293            candidates.push(form);
294        }
295    }
296    candidates
297}
298
299async fn get_lock_chapter_content_state_for_page(
300    conn: &mut PgConnection,
301    user_id: Option<Uuid>,
302    page: &Page,
303    cache: &mut CourseMaterialPageContentFilterCache,
304) -> ModelResult<Option<LockChapterContentState>> {
305    let Some(chapter_id) = page.chapter_id else {
306        return Ok(None);
307    };
308
309    if let Some(state) = cache.chapter_lock_content_states.get(&chapter_id).copied() {
310        return Ok(Some(state));
311    }
312
313    let state = match (user_id, page.course_id) {
314        (Some(user_id), Some(course_id)) => {
315            let course_chapter_locking_enabled = if let Some(enabled) = cache
316                .course_chapter_locking_enabled
317                .get(&course_id)
318                .copied()
319            {
320                enabled
321            } else {
322                let enabled = courses::get_course(conn, course_id)
323                    .await?
324                    .chapter_locking_enabled;
325                cache
326                    .course_chapter_locking_enabled
327                    .insert(course_id, enabled);
328                enabled
329            };
330
331            if !course_chapter_locking_enabled {
332                LockChapterContentState::NotLocked
333            } else {
334                let is_locked = matches!(
335                    user_chapter_locking_statuses::get_or_init_status(
336                        conn,
337                        user_id,
338                        chapter_id,
339                        Some(course_id),
340                        Some(course_chapter_locking_enabled),
341                    )
342                    .await?,
343                    Some(user_chapter_locking_statuses::ChapterLockingStatus::CompletedAndLocked)
344                );
345
346                if !is_locked {
347                    LockChapterContentState::NotLocked
348                } else if user_exercise_states::has_pending_manual_reviews_in_chapter(
349                    conn, user_id, chapter_id,
350                )
351                .await?
352                {
353                    LockChapterContentState::WaitingTeacherReview
354                } else {
355                    LockChapterContentState::Visible
356                }
357            }
358        }
359        _ => LockChapterContentState::NotLocked,
360    };
361
362    cache.chapter_lock_content_states.insert(chapter_id, state);
363    Ok(Some(state))
364}
365
366async fn filter_course_material_page_with_cache(
367    conn: &mut PgConnection,
368    user_id: Option<Uuid>,
369    page: Page,
370    cache: &mut CourseMaterialPageContentFilterCache,
371) -> ModelResult<(Page, Option<LockChapterContentState>)> {
372    let mut blocks = match page.blocks_cloned() {
373        Ok(blocks) => blocks,
374        Err(e) => {
375            tracing::warn!(
376                "Failed to deserialize page content for page {}: {}. Falling back to empty blocks.",
377                page.id,
378                e
379            );
380            vec![]
381        }
382    };
383
384    blocks = replace_duplicate_client_ids(blocks);
385
386    let lock_chapter_content_state =
387        get_lock_chapter_content_state_for_page(conn, user_id, &page, cache).await?;
388    if let Some(lock_state) = lock_chapter_content_state {
389        blocks = filter_lock_chapter_blocks(
390            blocks,
391            matches!(lock_state, LockChapterContentState::Visible),
392        );
393    }
394
395    let mut filtered_page = page;
396    filtered_page.content = serde_json::to_value(blocks)?;
397    Ok((filtered_page, lock_chapter_content_state))
398}
399
400pub async fn filter_course_material_page(
401    conn: &mut PgConnection,
402    user_id: Option<Uuid>,
403    page: Page,
404) -> ModelResult<Page> {
405    let mut cache = CourseMaterialPageContentFilterCache::default();
406    Ok(
407        filter_course_material_page_with_cache(conn, user_id, page, &mut cache)
408            .await?
409            .0,
410    )
411}
412
413pub async fn filter_course_material_pages(
414    conn: &mut PgConnection,
415    user_id: Option<Uuid>,
416    pages: Vec<Page>,
417) -> ModelResult<Vec<Page>> {
418    let mut cache = CourseMaterialPageContentFilterCache::default();
419    let mut filtered_pages = Vec::with_capacity(pages.len());
420    for page in pages {
421        filtered_pages.push(
422            filter_course_material_page_with_cache(conn, user_id, page, &mut cache)
423                .await?
424                .0,
425        );
426    }
427    Ok(filtered_pages)
428}
429
430pub async fn filter_course_material_pages_with_exercises(
431    conn: &mut PgConnection,
432    user_id: Option<Uuid>,
433    pages_with_exercises: Vec<PageWithExercises>,
434) -> ModelResult<Vec<PageWithExercises>> {
435    let mut cache = CourseMaterialPageContentFilterCache::default();
436    let mut filtered_pages = Vec::with_capacity(pages_with_exercises.len());
437    for page_with_exercises in pages_with_exercises {
438        let (page, _) = filter_course_material_page_with_cache(
439            conn,
440            user_id,
441            page_with_exercises.page,
442            &mut cache,
443        )
444        .await?;
445        filtered_pages.push(PageWithExercises {
446            page,
447            exercises: page_with_exercises.exercises,
448        });
449    }
450    Ok(filtered_pages)
451}
452
453/// Represents the subset of page fields that are required to create a new page.
454#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
455
456pub struct NewPage {
457    pub exercises: Vec<CmsPageExercise>,
458    pub exercise_slides: Vec<CmsPageExerciseSlide>,
459    pub exercise_tasks: Vec<CmsPageExerciseTask>,
460    pub content: Vec<GutenbergBlock>,
461    pub url_path: String,
462    pub title: String,
463    pub course_id: Option<Uuid>,
464    pub exam_id: Option<Uuid>,
465    pub chapter_id: Option<Uuid>,
466    /// If set, set this page to be the front page of this course part.
467    pub front_page_of_chapter_id: Option<Uuid>,
468    /// Read from the course's settings if None. If course_id is None as well, defaults to "simple"
469    pub content_search_language: Option<String>,
470    pub hidden: bool,
471}
472
473/// Represents the subset of page fields that can be updated from the main frontend dialog "Edit page details".
474#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
475
476pub struct PageDetailsUpdate {
477    pub title: String,
478    pub url_path: String,
479}
480
481#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
482
483pub struct NormalizedCmsExerciseTask {
484    pub id: Uuid,
485    pub exercise_type: String,
486    pub assignment: serde_json::Value,
487    pub private_spec: Option<serde_json::Value>,
488}
489
490#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
491
492pub struct PageRoutingData {
493    pub url_path: String,
494    pub title: String,
495    pub page_id: Uuid,
496    pub chapter_number: i32,
497    pub chapter_id: Uuid,
498    pub chapter_opens_at: Option<DateTime<Utc>>,
499    pub chapter_front_page_id: Option<Uuid>,
500}
501
502#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
503pub struct PageMetadata {
504    pub page_id: Uuid,
505    pub order_number: i32,
506    pub chapter_id: Option<Uuid>,
507    pub chapter_number: Option<i32>,
508    pub course_id: Option<Uuid>,
509    pub exam_id: Option<Uuid>,
510}
511
512#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
513
514pub struct PageChapterAndCourseInformation {
515    pub chapter_name: Option<String>,
516    pub chapter_number: Option<i32>,
517    pub course_name: Option<String>,
518    pub course_slug: Option<String>,
519    pub chapter_front_page_id: Option<Uuid>,
520    pub chapter_front_page_url_path: Option<String>,
521    pub organization_slug: Option<String>,
522}
523
524#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
525
526pub struct PageSearchResult {
527    pub id: Uuid,
528    pub title_headline: Option<String>,
529    pub rank: Option<f32>,
530    pub content_headline: Option<String>,
531    pub url_path: String,
532    pub chapter_name: Option<String>,
533}
534
535#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
536
537pub struct ContentManagementPage {
538    pub page: Page,
539    pub exercises: Vec<CmsPageExercise>,
540    pub exercise_slides: Vec<CmsPageExerciseSlide>,
541    pub exercise_tasks: Vec<CmsPageExerciseTask>,
542    pub peer_or_self_review_configs: Vec<CmsPeerOrSelfReviewConfig>,
543    pub peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion>,
544    pub organization_id: Uuid,
545}
546
547#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
548
549pub struct SearchRequest {
550    pub query: String,
551}
552#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
553
554pub struct PageNavigationInformation {
555    pub chapter_front_page: Option<PageRoutingData>,
556    pub next_page: Option<PageRoutingData>,
557    pub previous_page: Option<PageRoutingData>,
558}
559
560#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
561
562pub struct ExerciseWithExerciseTasks {
563    id: Uuid,
564    created_at: DateTime<Utc>,
565    updated_at: DateTime<Utc>,
566    course_id: Uuid,
567    deleted_at: Option<DateTime<Utc>>,
568    name: String,
569    deadline: Option<DateTime<Utc>>,
570    page_id: Uuid,
571    exercise_tasks: Vec<ExerciseTask>,
572    score_maximum: i32,
573}
574
575#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
576
577pub struct IsChapterFrontPage {
578    pub is_chapter_front_page: bool,
579}
580
581#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
582
583pub struct HistoryRestoreData {
584    pub history_id: Uuid,
585}
586
587#[derive(Debug, Serialize, Deserialize)]
588pub struct NewCoursePage<'a> {
589    pub content: Vec<GutenbergBlock>,
590    pub course_id: Uuid,
591    pub order_number: i32,
592    pub title: &'a str,
593    pub hidden: bool,
594    pub url_path: &'a str,
595}
596
597impl<'a> NewCoursePage<'a> {
598    /// Creates `NewCoursePage` with provided values that is public by default.
599    pub fn new(course_id: Uuid, order_number: i32, url_path: &'a str, title: &'a str) -> Self {
600        Self {
601            content: Default::default(),
602            course_id,
603            order_number,
604            title,
605            hidden: false,
606            url_path,
607        }
608    }
609
610    /// Creates a new `NewCoursePage` for the same course as this one and increments the page number.
611    pub fn followed_by(&self, url_path: &'a str, title: &'a str) -> Self {
612        Self::new(self.course_id, self.order_number + 1, url_path, title)
613    }
614
615    /// Sets the content of this page.
616    pub fn set_content(mut self, content: Vec<GutenbergBlock>) -> Self {
617        self.content = content;
618        self
619    }
620
621    /// Sets the hidden status of this page.
622    pub fn set_hidden(mut self, hidden: bool) -> Self {
623        self.hidden = hidden;
624        self
625    }
626}
627
628pub async fn insert_course_page(
629    conn: &mut PgConnection,
630    new_course_page: &NewCoursePage<'_>,
631    author: Uuid,
632) -> ModelResult<(Uuid, Uuid)> {
633    let course = crate::courses::get_course(&mut *conn, new_course_page.course_id).await?;
634    let mut tx = conn.begin().await?;
635    let page = insert_page_row(
636        &mut tx,
637        PageRow {
638            id: PKeyPolicy::Generate,
639            course: Some(&course),
640            exam_id: None,
641            chapter_id: None,
642            url_path: new_course_page.url_path,
643            title: new_course_page.title,
644            order_number: new_course_page.order_number,
645            hidden: new_course_page.hidden,
646            content_search_language: None,
647            page_language_group_id: None,
648        },
649        &serde_json::to_value(&new_course_page.content)?,
650    )
651    .await?;
652    let history_id = crate::page_history::insert(
653        &mut tx,
654        PKeyPolicy::Generate,
655        page.id,
656        new_course_page.title,
657        &PageHistoryContent {
658            content: serde_json::Value::Array(vec![]),
659            exercises: vec![],
660            exercise_slides: vec![],
661            exercise_tasks: vec![],
662            peer_or_self_review_configs: Vec::new(),
663            peer_or_self_review_questions: Vec::new(),
664        },
665        HistoryChangeReason::PageSaved,
666        HistoryAuthor::user(author),
667        None,
668    )
669    .await?;
670    tx.commit().await?;
671    Ok((page.id, history_id))
672}
673
674pub async fn insert_exam_page(
675    conn: &mut PgConnection,
676    exam_id: Uuid,
677    page: NewPage,
678    author: Uuid,
679) -> ModelResult<(Uuid, Uuid)> {
680    let mut tx = conn.begin().await?;
681    let inserted = insert_page_row(
682        &mut tx,
683        PageRow {
684            id: PKeyPolicy::Generate,
685            course: None,
686            exam_id: Some(exam_id),
687            chapter_id: None,
688            url_path: &page.url_path,
689            title: &page.title,
690            order_number: 0,
691            hidden: page.hidden,
692            content_search_language: page.content_search_language.as_deref(),
693            page_language_group_id: None,
694        },
695        &serde_json::to_value(&page.content)?,
696    )
697    .await?;
698
699    let history_id = crate::page_history::insert(
700        &mut tx,
701        PKeyPolicy::Generate,
702        inserted.id,
703        page.title.as_str(),
704        &PageHistoryContent {
705            content: serde_json::to_value(page.content)?,
706            exercises: page.exercises,
707            exercise_slides: page.exercise_slides,
708            exercise_tasks: page.exercise_tasks,
709            peer_or_self_review_configs: Vec::new(),
710            peer_or_self_review_questions: Vec::new(),
711        },
712        HistoryChangeReason::PageSaved,
713        HistoryAuthor::user(author),
714        None,
715    )
716    .await?;
717    tx.commit().await?;
718    Ok((inserted.id, history_id))
719}
720
721pub async fn get_course_and_exam_id(
722    conn: &mut PgConnection,
723    id: Uuid,
724) -> ModelResult<CourseOrExamId> {
725    let res = sqlx::query!(
726        "
727SELECT course_id, exam_id
728FROM pages
729WHERE id = $1
730  AND deleted_at IS NULL;
731        ",
732        id
733    )
734    .fetch_one(conn)
735    .await?;
736    CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
737}
738
739#[derive(Copy, Clone, Debug, PartialEq, Eq)]
740pub enum PageVisibility {
741    Any,
742    Public,
743    Hidden,
744}
745
746impl PageVisibility {
747    /// Hacky way to implement a nullable boolean filter. Based on the idea that
748    /// `null IS DISTINCT FROM anything` in PostgreSQL.
749    ///
750    /// More information at: <https://www.postgresql.org/docs/current/functions-comparison.html>
751    ///
752    /// # Examples
753    ///
754    /// ```ignore
755    /// # use headless_lms_models::{ModelResult, pages::PageVisibility};
756    /// # use sqlx::PgConnection;
757    /// # async fn random_function_1(conn: &mut PgConnection) -> ModelResult<()> {
758    /// // Evaluates to "hidden <> NULL"
759    /// let visibility = PageVisibility::Any;
760    /// sqlx::query!(
761    ///     "SELECT id FROM pages WHERE hidden IS DISTINCT FROM $1",
762    ///     visibility.get_inverse_visibility_filter(),
763    /// )
764    /// .fetch_all(conn)
765    /// .await?;
766    /// # Ok(())
767    /// # }
768    ///
769    /// # async fn random_function_2(conn: &mut PgConnection) -> ModelResult<()> {
770    /// // Evaluates to "hidden <> true"
771    /// let visibility = PageVisibility::Public;
772    /// sqlx::query!(
773    ///     "SELECT id FROM pages WHERE hidden IS DISTINCT FROM $1",
774    ///     visibility.get_inverse_visibility_filter(),
775    /// )
776    /// .fetch_all(conn)
777    /// .await?;
778    /// # Ok(())
779    /// # }
780    /// ```
781    fn get_inverse_visibility_filter(&self) -> Option<bool> {
782        match self {
783            PageVisibility::Any => None,
784            PageVisibility::Public => Some(true),
785            PageVisibility::Hidden => Some(false),
786        }
787    }
788}
789
790/// Gets all pages that belong to the given course that match the visibility filter.
791pub async fn get_all_by_course_id_and_visibility(
792    conn: &mut PgConnection,
793    course_id: Uuid,
794    page_visibility: PageVisibility,
795) -> ModelResult<Vec<Page>> {
796    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
797    let res = sqlx::query_as!(
798        Page,
799        "
800SELECT id,
801  created_at,
802  updated_at,
803  course_id,
804  exam_id,
805  chapter_id,
806  url_path,
807  title,
808  deleted_at,
809  content,
810  order_number,
811  copied_from,
812  hidden,
813  page_language_group_id
814FROM pages
815WHERE course_id = $1
816  AND hidden IS DISTINCT FROM $2
817  AND deleted_at IS NULL
818    ",
819        course_id,
820        inverse_visibility_filter,
821    )
822    .fetch_all(conn)
823    .await?;
824    Ok(res)
825}
826
827/// Gets all pages that belong to the given course but not in any chapter.
828pub async fn get_course_top_level_pages_by_course_id_and_visibility(
829    conn: &mut PgConnection,
830    course_id: Uuid,
831    page_visibility: PageVisibility,
832) -> ModelResult<Vec<Page>> {
833    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
834    let pages = sqlx::query_as!(
835        Page,
836        "
837SELECT id,
838  created_at,
839  updated_at,
840  course_id,
841  exam_id,
842  chapter_id,
843  url_path,
844  title,
845  deleted_at,
846  content,
847  order_number,
848  copied_from,
849  hidden,
850  page_language_group_id
851FROM pages p
852WHERE course_id = $1
853  AND hidden IS DISTINCT FROM $2
854  AND p.chapter_id IS NULL
855  AND p.deleted_at IS NULL
856        ",
857        course_id,
858        inverse_visibility_filter,
859    )
860    .fetch_all(conn)
861    .await?;
862    Ok(pages)
863}
864
865/// Gets all pages that belong to the given chapter that match the visibility filter.
866pub async fn get_course_pages_by_chapter_id_and_visibility(
867    conn: &mut PgConnection,
868    chapter_id: Uuid,
869    page_visibility: PageVisibility,
870) -> ModelResult<Vec<Page>> {
871    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
872    let res = sqlx::query_as!(
873        Page,
874        "
875SELECT id,
876  created_at,
877  updated_at,
878  course_id,
879  exam_id,
880  chapter_id,
881  url_path,
882  title,
883  deleted_at,
884  content,
885  order_number,
886  copied_from,
887  hidden,
888  page_language_group_id
889FROM pages
890WHERE chapter_id = $1
891  AND hidden IS DISTINCT FROM $2
892  AND deleted_at IS NULL
893    ",
894        chapter_id,
895        inverse_visibility_filter,
896    )
897    .fetch_all(conn)
898    .await?;
899    Ok(res)
900}
901
902/// Titles for a set of pages, for annotating rows that only carry a page id.
903pub async fn get_titles_by_ids(
904    conn: &mut PgConnection,
905    page_ids: &[Uuid],
906) -> ModelResult<HashMap<Uuid, String>> {
907    let rows = sqlx::query!(
908        "
909SELECT id,
910  title
911FROM pages
912WHERE id = ANY($1)
913  AND deleted_at IS NULL
914        ",
915        page_ids,
916    )
917    .fetch_all(conn)
918    .await?;
919    Ok(rows.into_iter().map(|r| (r.id, r.title)).collect())
920}
921
922pub async fn get_page(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<Page> {
923    let page = sqlx::query_as!(
924        Page,
925        "
926SELECT id,
927  created_at,
928  updated_at,
929  course_id,
930  exam_id,
931  chapter_id,
932  url_path,
933  title,
934  deleted_at,
935  content,
936  order_number,
937  copied_from,
938  hidden,
939  page_language_group_id
940FROM pages
941WHERE id = $1;
942",
943        page_id
944    )
945    .fetch_one(conn)
946    .await?;
947    Ok(page)
948}
949
950/// The fields the chatbot needs to describe a page: enough to build the page-context message,
951/// none of the Gutenberg `content` JSONB.
952pub struct PageChatbotContext {
953    pub id: Uuid,
954    pub title: String,
955    pub chapter_name: Option<String>,
956    pub course_id: Option<Uuid>,
957    pub hidden: bool,
958    pub deleted_at: Option<DateTime<Utc>>,
959}
960
961/// Like [get_page], but projects only the columns the chatbot needs and folds in the chapter name,
962/// so the caller does not also have to fetch the whole page (including `content`) or the chapter.
963///
964/// `chapter_name` is `None` when the page has no chapter or when its chapter has been deleted, the
965/// same as looking up the chapter separately and discarding a `NotFound` error would give.
966pub async fn get_page_chatbot_context(
967    conn: &mut PgConnection,
968    page_id: Uuid,
969) -> ModelResult<PageChatbotContext> {
970    let page = sqlx::query_as!(
971        PageChatbotContext,
972        r#"
973SELECT pages.id AS "id!",
974  pages.title AS "title!",
975  chapters.name AS "chapter_name?",
976  pages.course_id,
977  pages.hidden AS "hidden!",
978  pages.deleted_at
979FROM pages
980LEFT JOIN chapters ON chapters.id = pages.chapter_id AND chapters.deleted_at IS NULL
981WHERE pages.id = $1;
982"#,
983        page_id
984    )
985    .fetch_one(conn)
986    .await?;
987    Ok(page)
988}
989
990pub async fn get_front_page_by_chapter_id(
991    conn: &mut PgConnection,
992    chapter_id: Uuid,
993) -> ModelResult<Option<Page>> {
994    let page = sqlx::query_as!(
995        Page,
996        r#"
997SELECT p.id,
998  p.created_at,
999  p.updated_at,
1000  p.course_id,
1001  p.exam_id,
1002  p.chapter_id,
1003  p.url_path,
1004  p.title,
1005  p.deleted_at,
1006  p.content,
1007  p.order_number,
1008  p.copied_from,
1009  p.hidden,
1010  p.page_language_group_id
1011FROM chapters c
1012  JOIN pages p ON p.id = c.front_page_id
1013WHERE c.id = $1
1014  AND c.deleted_at IS NULL
1015  AND p.deleted_at IS NULL
1016        "#,
1017        chapter_id
1018    )
1019    .fetch_optional(conn)
1020    .await?;
1021    Ok(page)
1022}
1023
1024pub async fn get_page_info(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<PageInfo> {
1025    let res = sqlx::query_as!(
1026        PageInfo,
1027        r#"
1028    SELECT
1029        p.id as "page_id!",
1030        p.title as "page_title!",
1031        c.id as "course_id?",
1032        c.name as "course_name?",
1033        c.slug as "course_slug?",
1034        o.slug as "organization_slug?"
1035    FROM pages p
1036    LEFT JOIN courses c
1037        on c.id = p.course_id
1038    LEFT JOIN organizations o
1039        on o.id = c.organization_id
1040    WHERE p.id = $1;
1041        "#,
1042        page_id
1043    )
1044    .fetch_one(conn)
1045    .await?;
1046
1047    Ok(res)
1048}
1049
1050pub async fn get_page_info_special_for_course(
1051    conn: &mut PgConnection,
1052    course_id: Uuid,
1053) -> ModelResult<Vec<PageInfoSpecial>> {
1054    let inverse_visibility_filter = PageVisibility::Public.get_inverse_visibility_filter();
1055    // if chapter_id is null then chapter_number and title should be null
1056    let res = sqlx::query_as!(
1057        PageInfoSpecial,
1058        r#"
1059    SELECT
1060        p.id AS "page_id!",
1061        p.url_path AS "url_path!",
1062        p.title AS "page_title!",
1063        p.order_number AS "order_number!",
1064        p.content AS "content!",
1065        c.name AS "chapter_title?",
1066        c.chapter_number AS "chapter_number?",
1067        m.name AS "module_name?",
1068        m.order_number AS "module_number?"
1069    FROM pages p
1070    LEFT JOIN chapters c
1071        ON c.id = p.chapter_id
1072    LEFT JOIN course_modules m
1073        ON m.id = c.course_module_id
1074    WHERE p.course_id = $1
1075    AND p.deleted_at IS NULL
1076    AND c.deleted_at IS NULL
1077    AND m.deleted_at IS NULL
1078    AND p.hidden IS DISTINCT FROM $2
1079        "#,
1080        course_id,
1081        inverse_visibility_filter,
1082    )
1083    .fetch_all(conn)
1084    .await?;
1085
1086    Ok(res)
1087}
1088
1089async fn get_page_by_stored_path(
1090    conn: &mut PgConnection,
1091    course_id: Uuid,
1092    url_path: &str,
1093) -> ModelResult<Option<Page>> {
1094    let page = sqlx::query_as!(
1095        Page,
1096        "
1097SELECT pages.id,
1098  pages.created_at,
1099  pages.updated_at,
1100  pages.course_id,
1101  pages.exam_id,
1102  pages.chapter_id,
1103  pages.url_path,
1104  pages.title,
1105  pages.deleted_at,
1106  pages.content,
1107  pages.order_number,
1108  pages.copied_from,
1109  pages.hidden,
1110  pages.page_language_group_id
1111FROM pages
1112WHERE pages.course_id = $1
1113  AND url_path = $2
1114  AND pages.deleted_at IS NULL;
1115        ",
1116        course_id,
1117        url_path
1118    )
1119    .fetch_optional(conn)
1120    .await?;
1121    Ok(page)
1122}
1123
1124/// Looks up a non-deleted page in `course_id` whose stored `url_path` matches `url_path` in
1125/// either the decoded-canonical or the legacy fully-encoded form (see
1126/// [`url_path_lookup_candidates`]). Shared by the page lookup and its tests so both exercise
1127/// the same candidate-matching logic.
1128async fn find_page_by_requested_path(
1129    conn: &mut PgConnection,
1130    course_id: Uuid,
1131    url_path: &str,
1132) -> ModelResult<Option<Page>> {
1133    for candidate in url_path_lookup_candidates(url_path) {
1134        if let Some(page) = get_page_by_stored_path(conn, course_id, &candidate).await? {
1135            return Ok(Some(page));
1136        }
1137    }
1138    Ok(None)
1139}
1140
1141pub async fn get_page_with_user_data_by_path(
1142    conn: &mut PgConnection,
1143    user_id: Option<Uuid>,
1144    course_data: &CourseContextData,
1145    url_path: &str,
1146    file_store: &dyn FileStore,
1147    app_conf: &ApplicationConfiguration,
1148) -> ModelResult<CoursePageWithUserData> {
1149    if let Some(page) = find_page_by_requested_path(conn, course_data.id, url_path).await? {
1150        return get_course_page_with_user_data_from_selected_page(
1151            conn,
1152            user_id,
1153            page,
1154            false,
1155            course_data.is_test_mode,
1156            file_store,
1157            app_conf,
1158        )
1159        .await;
1160    }
1161
1162    if let Some(redirected_page) =
1163        try_to_find_redirected_page(conn, course_data.id, url_path).await?
1164    {
1165        return get_course_page_with_user_data_from_selected_page(
1166            conn,
1167            user_id,
1168            redirected_page,
1169            true,
1170            course_data.is_test_mode,
1171            file_store,
1172            app_conf,
1173        )
1174        .await;
1175    }
1176
1177    Err(model_err!(NotFound, "Page not found".to_string()))
1178}
1179
1180pub async fn try_to_find_redirected_page(
1181    conn: &mut PgConnection,
1182    course_id: Uuid,
1183    url_path: &str,
1184) -> ModelResult<Option<Page>> {
1185    for candidate in url_path_lookup_candidates(url_path) {
1186        if let Some(page) =
1187            try_to_find_redirected_page_by_stored_path(conn, course_id, &candidate).await?
1188        {
1189            return Ok(Some(page));
1190        }
1191    }
1192    Ok(None)
1193}
1194
1195async fn try_to_find_redirected_page_by_stored_path(
1196    conn: &mut PgConnection,
1197    course_id: Uuid,
1198    url_path: &str,
1199) -> ModelResult<Option<Page>> {
1200    let page = sqlx::query_as!(
1201        Page,
1202        "
1203SELECT pages.id,
1204  pages.created_at,
1205  pages.updated_at,
1206  pages.course_id,
1207  pages.exam_id,
1208  pages.chapter_id,
1209  pages.url_path,
1210  pages.title,
1211  pages.deleted_at,
1212  pages.content,
1213  pages.order_number,
1214  pages.copied_from,
1215  pages.hidden,
1216  pages.page_language_group_id
1217FROM url_redirections
1218  JOIN pages on pages.id = url_redirections.destination_page_id
1219WHERE url_redirections.course_id = $1
1220  AND old_url_path = $2
1221  AND url_redirections.deleted_at IS NULL
1222  AND pages.deleted_at IS NULL;
1223    ",
1224        course_id,
1225        url_path
1226    )
1227    .fetch_optional(conn)
1228    .await?;
1229    Ok(page)
1230}
1231
1232pub async fn get_course_page_with_user_data_from_selected_page(
1233    conn: &mut PgConnection,
1234    user_id: Option<Uuid>,
1235    page: Page,
1236    was_redirected: bool,
1237    is_test_mode: bool,
1238    file_store: &dyn FileStore,
1239    app_conf: &ApplicationConfiguration,
1240) -> ModelResult<CoursePageWithUserData> {
1241    let mut content_filter_cache = CourseMaterialPageContentFilterCache::default();
1242    let (filtered_page, lock_chapter_content_state) =
1243        filter_course_material_page_with_cache(conn, user_id, page, &mut content_filter_cache)
1244            .await?;
1245
1246    if let Some(course_id) = filtered_page.course_id
1247        && let Some(user_id) = user_id
1248    {
1249        let instance =
1250            course_instances::current_course_instance_of_user(conn, user_id, course_id).await?;
1251        let settings =
1252            user_course_settings::get_user_course_settings_by_course_id(conn, user_id, course_id)
1253                .await?;
1254        let course = courses::get_course(conn, course_id).await?;
1255        let organization = Organization::from_database_organization(
1256            crate::organizations::get_organization(conn, course.organization_id).await?,
1257            file_store,
1258            app_conf,
1259        );
1260        return Ok(CoursePageWithUserData {
1261            page: filtered_page,
1262            instance,
1263            settings,
1264            course: Some(course.into()),
1265            was_redirected,
1266            is_test_mode,
1267            organization: Some(organization),
1268            lock_chapter_content_state,
1269        });
1270    }
1271    Ok(CoursePageWithUserData {
1272        page: filtered_page,
1273        instance: None,
1274        settings: None,
1275        course: None,
1276        was_redirected,
1277        is_test_mode,
1278        organization: None,
1279        lock_chapter_content_state,
1280    })
1281}
1282
1283pub async fn get_page_with_exercises(
1284    conn: &mut PgConnection,
1285    page_id: Uuid,
1286) -> ModelResult<ContentManagementPage> {
1287    let page = get_page(&mut *conn, page_id).await?;
1288    with_exercises(conn, page).await
1289}
1290
1291/// The fetched page with its exercises, as the CMS edits it.
1292pub async fn with_exercises(
1293    conn: &mut PgConnection,
1294    page: Page,
1295) -> ModelResult<ContentManagementPage> {
1296    let exercises = exercises_of_pages(&mut *conn, &[page.id])
1297        .await?
1298        .remove(&page.id)
1299        .unwrap_or_default();
1300    let organization_id = get_organization_id(&mut *conn, page.id).await?;
1301    Ok(ContentManagementPage {
1302        page,
1303        exercises: exercises.exercises,
1304        exercise_slides: exercises.exercise_slides,
1305        exercise_tasks: exercises.exercise_tasks,
1306        peer_or_self_review_configs: exercises.peer_or_self_review_configs,
1307        peer_or_self_review_questions: exercises.peer_or_self_review_questions,
1308        organization_id,
1309    })
1310}
1311
1312/// A page's exercises with their slides, tasks and custom peer-review settings, as the CMS edits
1313/// them.
1314#[derive(Debug, Default)]
1315pub struct PageExercises {
1316    pub exercises: Vec<CmsPageExercise>,
1317    pub exercise_slides: Vec<CmsPageExerciseSlide>,
1318    pub exercise_tasks: Vec<CmsPageExerciseTask>,
1319    pub peer_or_self_review_configs: Vec<CmsPeerOrSelfReviewConfig>,
1320    pub peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion>,
1321}
1322
1323/// The exercises of each page, by page id. Pages without exercises are left out.
1324pub async fn exercises_of_pages(
1325    conn: &mut PgConnection,
1326    page_ids: &[Uuid],
1327) -> ModelResult<HashMap<Uuid, PageExercises>> {
1328    let mut by_page: HashMap<Uuid, PageExercises> = HashMap::new();
1329    if page_ids.is_empty() {
1330        return Ok(by_page);
1331    }
1332    let peer_or_self_review_configs =
1333        crate::peer_or_self_review_configs::get_peer_reviews_by_page_ids(conn, page_ids)
1334            .await?
1335            .into_iter()
1336            .flat_map(|pr| pr.exercise_id.map(|id| (id, pr)))
1337            .collect::<HashMap<_, _>>();
1338    let peer_or_self_review_questions =
1339        crate::peer_or_self_review_questions::get_by_page_ids(conn, page_ids)
1340            .await?
1341            .into_iter()
1342            .into_group_map_by(|prq| prq.peer_or_self_review_config_id);
1343    let exercises = crate::exercises::get_exercises_by_page_ids(&mut *conn, page_ids).await?;
1344
1345    let mut page_of_exercise: HashMap<Uuid, Uuid> = HashMap::new();
1346    for exercise in exercises {
1347        page_of_exercise.insert(exercise.id, exercise.page_id);
1348        let page = by_page.entry(exercise.page_id).or_default();
1349        let config = peer_or_self_review_configs.get(&exercise.id);
1350        let questions = config.and_then(|prc| peer_or_self_review_questions.get(&prc.id));
1351        if let Some(config) = config {
1352            page.peer_or_self_review_configs.push(config.clone());
1353            page.peer_or_self_review_questions
1354                .extend(questions.into_iter().flatten().cloned());
1355        }
1356        let (config, questions) = if exercise.needs_peer_review
1357            && exercise.use_course_default_peer_or_self_review_config
1358        {
1359            (None, None)
1360        } else {
1361            (config.cloned(), questions.cloned())
1362        };
1363        page.exercises
1364            .push(CmsPageExercise::from_exercise_and_peer_review_data(
1365                exercise, config, questions,
1366            ));
1367    }
1368
1369    let exercise_ids: Vec<Uuid> = page_of_exercise.keys().copied().collect();
1370    let slides =
1371        crate::exercise_slides::get_exercise_slides_by_exercise_ids(&mut *conn, &exercise_ids)
1372            .await?;
1373    let mut page_of_slide: HashMap<Uuid, Uuid> = HashMap::new();
1374    for slide in slides {
1375        let Some(page_id) = page_of_exercise.get(&slide.exercise_id) else {
1376            continue;
1377        };
1378        page_of_slide.insert(slide.id, *page_id);
1379        by_page
1380            .entry(*page_id)
1381            .or_default()
1382            .exercise_slides
1383            .push(slide.into());
1384    }
1385
1386    let slide_ids: Vec<Uuid> = page_of_slide.keys().copied().collect();
1387    let tasks =
1388        crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_ids(&mut *conn, &slide_ids)
1389            .await?;
1390    let mut spec_files_by_task = crate::exercise_task_spec_files::get_by_exercise_task_ids(
1391        &mut *conn,
1392        &tasks.iter().map(|task| task.id).collect::<Vec<Uuid>>(),
1393        crate::exercise_task_spec_files::SpecKind::Private,
1394    )
1395    .await?;
1396    for task in tasks {
1397        let Some(page_id) = page_of_slide.get(&task.exercise_slide_id) else {
1398            continue;
1399        };
1400        let private_spec_files = spec_files_by_task.remove(&task.id).unwrap_or_default();
1401        by_page
1402            .entry(*page_id)
1403            .or_default()
1404            .exercise_tasks
1405            .push(CmsPageExerciseTask::from_task(task, private_spec_files));
1406    }
1407    Ok(by_page)
1408}
1409
1410/// Gets the page that belongs to the given exam. For exams, the page visibility is ignored.
1411pub async fn get_by_exam_id(conn: &mut PgConnection, exam_id: Uuid) -> ModelResult<Page> {
1412    let res = sqlx::query_as!(
1413        Page,
1414        "
1415SELECT pages.id,
1416  pages.created_at,
1417  pages.updated_at,
1418  pages.course_id,
1419  pages.exam_id,
1420  pages.chapter_id,
1421  pages.url_path,
1422  pages.title,
1423  pages.deleted_at,
1424  pages.content,
1425  pages.order_number,
1426  pages.copied_from,
1427  pages.hidden,
1428  pages.page_language_group_id
1429FROM pages
1430WHERE exam_id = $1
1431AND pages.deleted_at IS NULL
1432",
1433        exam_id
1434    )
1435    .fetch_one(conn)
1436    .await?;
1437    Ok(res)
1438}
1439
1440pub async fn get_pages_by_course_id(
1441    conn: &mut PgConnection,
1442    course_id: Uuid,
1443) -> ModelResult<Vec<Page>> {
1444    let res = sqlx::query_as!(
1445        Page,
1446        "
1447SELECT pages.id,
1448  pages.created_at,
1449  pages.updated_at,
1450  pages.course_id,
1451  pages.exam_id,
1452  pages.chapter_id,
1453  pages.url_path,
1454  pages.title,
1455  pages.deleted_at,
1456  pages.content,
1457  pages.order_number,
1458  pages.copied_from,
1459  pages.hidden,
1460  pages.page_language_group_id
1461FROM pages
1462WHERE course_id = $1
1463AND pages.deleted_at IS NULL
1464",
1465        course_id
1466    )
1467    .fetch_all(conn)
1468    .await?;
1469    Ok(res)
1470}
1471
1472#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
1473
1474pub struct CmsPageExercise {
1475    pub id: Uuid,
1476    pub name: String,
1477    pub order_number: i32,
1478    pub score_maximum: i32,
1479    pub max_tries_per_slide: Option<i32>,
1480    pub limit_number_of_tries: bool,
1481    pub deadline: Option<DateTime<Utc>>,
1482    pub needs_peer_review: bool,
1483    pub needs_self_review: bool,
1484    pub peer_or_self_review_config: Option<CmsPeerOrSelfReviewConfig>,
1485    pub peer_or_self_review_questions: Option<Vec<CmsPeerOrSelfReviewQuestion>>,
1486    pub use_course_default_peer_or_self_review_config: bool,
1487    pub teacher_reviews_answer_after_locking: bool,
1488}
1489
1490impl CmsPageExercise {
1491    fn from_exercise_and_peer_review_data(
1492        exercise: Exercise,
1493        peer_or_self_review_config: Option<CmsPeerOrSelfReviewConfig>,
1494        peer_or_self_review_questions: Option<Vec<CmsPeerOrSelfReviewQuestion>>,
1495    ) -> Self {
1496        Self {
1497            id: exercise.id,
1498            name: exercise.name,
1499            order_number: exercise.order_number,
1500            score_maximum: exercise.score_maximum,
1501            max_tries_per_slide: exercise.max_tries_per_slide,
1502            limit_number_of_tries: exercise.limit_number_of_tries,
1503            deadline: exercise.deadline,
1504            needs_peer_review: exercise.needs_peer_review,
1505            needs_self_review: exercise.needs_self_review,
1506            use_course_default_peer_or_self_review_config: exercise
1507                .use_course_default_peer_or_self_review_config,
1508            teacher_reviews_answer_after_locking: exercise.teacher_reviews_answer_after_locking,
1509            peer_or_self_review_config,
1510            peer_or_self_review_questions,
1511        }
1512    }
1513}
1514
1515#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
1516
1517pub struct CmsPageExerciseSlide {
1518    pub id: Uuid,
1519    pub exercise_id: Uuid,
1520    pub order_number: i32,
1521}
1522
1523impl From<ExerciseSlide> for CmsPageExerciseSlide {
1524    fn from(slide: ExerciseSlide) -> Self {
1525        Self {
1526            id: slide.id,
1527            exercise_id: slide.exercise_id,
1528            order_number: slide.order_number,
1529        }
1530    }
1531}
1532
1533#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
1534
1535pub struct CmsPageExerciseTask {
1536    pub id: Uuid,
1537    pub exercise_slide_id: Uuid,
1538    pub assignment: serde_json::Value,
1539    pub exercise_type: String,
1540    pub private_spec: Option<serde_json::Value>,
1541    pub order_number: i32,
1542    /// The stored files this task's private spec references, as the exercise service declared them
1543    /// in the editor. The host cannot read the spec, so this list is the only thing keeping the
1544    /// files from being reclaimed as abandoned uploads — and the CMS round-trips it through a block
1545    /// attribute, so a load that leaves it empty makes the next save drop the references.
1546    ///
1547    /// Only the private spec's. Each derived spec declares its own files in the response of the
1548    /// endpoint that produced it, since a derivation may upload files of its own.
1549    #[serde(default)]
1550    pub private_spec_files: Vec<Uuid>,
1551}
1552
1553impl CmsPageExerciseTask {
1554    /// Deliberately not a `From<ExerciseTask>`: the declared spec files live in their own table, so
1555    /// a conversion that could not reach them would quietly produce an empty list and the next save
1556    /// would drop every reference.
1557    pub fn from_task(task: ExerciseTask, private_spec_files: Vec<Uuid>) -> Self {
1558        CmsPageExerciseTask {
1559            id: task.id,
1560            exercise_slide_id: task.exercise_slide_id,
1561            assignment: task.assignment,
1562            exercise_type: task.exercise_type,
1563            private_spec: task.private_spec,
1564            order_number: task.order_number,
1565            private_spec_files,
1566        }
1567    }
1568}
1569
1570#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
1571
1572pub struct CmsPageUpdate {
1573    pub content: Vec<GutenbergBlock>,
1574    pub exercises: Vec<CmsPageExercise>,
1575    pub exercise_slides: Vec<CmsPageExerciseSlide>,
1576    pub exercise_tasks: Vec<CmsPageExerciseTask>,
1577    pub url_path: String,
1578    pub title: String,
1579    pub chapter_id: Option<Uuid>,
1580    pub hidden: bool,
1581}
1582
1583/// A CMS page save. It is refused as stale when `expected_updated_at` is set and the page has
1584/// changed since that version; leave it unset to overwrite.
1585#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
1586pub struct CmsPageSave {
1587    #[serde(flatten)]
1588    pub page: CmsPageUpdate,
1589    pub expected_updated_at: Option<DateTime<Utc>>,
1590}
1591
1592impl CmsPageUpdate {
1593    /// Checks that each exercise has at least one slide and each slide has at least one task.
1594    pub fn validate_exercise_data(&self) -> ModelResult<()> {
1595        let mut exercise_ids: HashMap<Uuid, bool> =
1596            self.exercises.iter().map(|x| (x.id, false)).collect();
1597        let mut slide_ids = self
1598            .exercise_slides
1599            .iter()
1600            .map(|x| {
1601                if let hash_map::Entry::Occupied(mut e) = exercise_ids.entry(x.exercise_id) {
1602                    e.insert(true);
1603                    Ok((x.id, false))
1604                } else {
1605                    Err(model_err!(
1606                        PreconditionFailed,
1607                        "Exercide ids in slides don't match.".to_string()
1608                    ))
1609                }
1610            })
1611            .collect::<ModelResult<HashMap<Uuid, bool>>>()?;
1612
1613        if let Some((exercise_id, _)) = exercise_ids.into_iter().find(|(_, x)| !x) {
1614            return Err(model_err!(
1615                PreconditionFailedWithCMSAnchorBlockId {
1616                    id: exercise_id,
1617                    description: "Exercise must have at least one slide.",
1618                },
1619                "Exercise must have at least one slide.".to_string()
1620            ));
1621        }
1622
1623        for task in self.exercise_tasks.iter() {
1624            if let hash_map::Entry::Occupied(mut e) = slide_ids.entry(task.exercise_slide_id) {
1625                e.insert(true);
1626            } else {
1627                return Err(model_err!(
1628                    PreconditionFailed,
1629                    "Exercise slide ids in tasks don't match.".to_string()
1630                ));
1631            }
1632        }
1633        if let Some((slide_id, _)) = slide_ids.into_iter().find(|(_, x)| !x) {
1634            return Err(model_err!(
1635                PreconditionFailedWithCMSAnchorBlockId {
1636                    id: slide_id,
1637                    description: "Exercise slide must have at least one task.",
1638                },
1639                "Exercise slide must have at least one task.".to_string()
1640            ));
1641        }
1642        Ok(())
1643    }
1644
1645    /// Detects exercises that share the same id within this single page and gives the duplicate
1646    /// occurrences fresh ids so they become independent exercises (#148).
1647    ///
1648    /// Duplicating an exercise block in the CMS (Gutenberg) keeps the original block's id (and the
1649    /// ids of its nested slides/tasks), so a saved page can contain several exercises that reference
1650    /// the same backend exercise. Without this, the upsert collapses them into a single row through
1651    /// `ON CONFLICT (id) DO UPDATE` and editing one secretly edits the others.
1652    ///
1653    /// The same id legitimately appearing once per page is how exercises move between pages, so only
1654    /// repeats *within the same page* are remapped. The duplicate's whole subtree (slides, tasks and
1655    /// any custom peer-review config/questions) is remapped consistently. Occurrences are matched in
1656    /// document order, which `normalizeDocument` guarantees is identical across the content and the
1657    /// flat exercise/slide/task lists.
1658    ///
1659    /// Returns a map from each regenerated duplicate's new exercise id to its original id, so callers
1660    /// can recover data still keyed by the original id (e.g. custom peer-review config/questions).
1661    pub fn regenerate_duplicate_exercise_ids(&mut self) -> HashMap<Uuid, Uuid> {
1662        // For every original exercise id, the new id to use for each occurrence (index 0 is the
1663        // first occurrence and keeps the original id).
1664        let mut exercise_ids_by_occurrence: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
1665        // Maps each regenerated duplicate's new id back to its original exercise id.
1666        let mut original_exercise_id_by_new_id: HashMap<Uuid, Uuid> = HashMap::new();
1667        let mut found_duplicate = false;
1668        for exercise in self.exercises.iter_mut() {
1669            let original_id = exercise.id;
1670            let occurrences = exercise_ids_by_occurrence.entry(original_id).or_default();
1671            let new_id = if occurrences.is_empty() {
1672                original_id
1673            } else {
1674                found_duplicate = true;
1675                Uuid::new_v4()
1676            };
1677            occurrences.push(new_id);
1678
1679            if new_id != original_id {
1680                exercise.id = new_id;
1681                original_exercise_id_by_new_id.insert(new_id, original_id);
1682                // Remap the duplicate's inline custom peer-review config/questions so they don't
1683                // collapse onto the original exercise's config.
1684                if let Some(config) = exercise.peer_or_self_review_config.as_mut() {
1685                    let questions = exercise
1686                        .peer_or_self_review_questions
1687                        .as_deref_mut()
1688                        .unwrap_or(&mut []);
1689                    fork_peer_or_self_review_config(config, questions, new_id, Uuid::new_v4);
1690                }
1691            }
1692        }
1693
1694        if !found_duplicate {
1695            return original_exercise_id_by_new_id;
1696        }
1697
1698        // Remap slides. The m-th occurrence of a slide id belongs to the m-th occurrence of its
1699        // exercise, because each duplicated exercise carries an identical copy of its slides.
1700        let mut slide_ids_by_occurrence: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
1701        for slide in self.exercise_slides.iter_mut() {
1702            let original_slide_id = slide.id;
1703            let occurrence = slide_ids_by_occurrence
1704                .get(&original_slide_id)
1705                .map_or(0, |occurrences| occurrences.len());
1706
1707            let new_exercise_id = exercise_ids_by_occurrence
1708                .get(&slide.exercise_id)
1709                .and_then(|occurrences| occurrences.get(occurrence))
1710                .copied()
1711                .unwrap_or(slide.exercise_id);
1712            let new_slide_id = if occurrence == 0 {
1713                original_slide_id
1714            } else {
1715                Uuid::new_v4()
1716            };
1717            slide_ids_by_occurrence
1718                .entry(original_slide_id)
1719                .or_default()
1720                .push(new_slide_id);
1721            slide.id = new_slide_id;
1722            slide.exercise_id = new_exercise_id;
1723        }
1724
1725        // Remap tasks the same way against the slide occurrences.
1726        let mut task_ids_by_occurrence: HashMap<Uuid, usize> = HashMap::new();
1727        for task in self.exercise_tasks.iter_mut() {
1728            let original_task_id = task.id;
1729            let occurrence = task_ids_by_occurrence.entry(original_task_id).or_insert(0);
1730
1731            let new_slide_id = slide_ids_by_occurrence
1732                .get(&task.exercise_slide_id)
1733                .and_then(|occurrences| occurrences.get(*occurrence))
1734                .copied()
1735                .unwrap_or(task.exercise_slide_id);
1736            if *occurrence > 0 {
1737                task.id = Uuid::new_v4();
1738            }
1739            task.exercise_slide_id = new_slide_id;
1740            *occurrence += 1;
1741        }
1742
1743        // Finally rewrite the exercise ids embedded in the page content so the duplicated block
1744        // points at its newly created exercise. Exercise blocks only exist at the top level.
1745        let mut content_occurrence: HashMap<Uuid, usize> = HashMap::new();
1746        for block in self.content.iter_mut() {
1747            let Some(id) = block.exercise_id() else {
1748                continue;
1749            };
1750            let occurrence = content_occurrence.entry(id).or_insert(0);
1751            if let Some(new_id) = exercise_ids_by_occurrence
1752                .get(&id)
1753                .and_then(|occurrences| occurrences.get(*occurrence))
1754                && *new_id != id
1755            {
1756                block
1757                    .attributes
1758                    .insert("id".to_string(), Value::String(new_id.to_string()));
1759            }
1760            *occurrence += 1;
1761        }
1762
1763        original_exercise_id_by_new_id
1764    }
1765}
1766
1767/// Gives `questions` fresh ids pointing at `target_config_id` when it differs from `source_config_id`
1768/// (i.e. they are being cloned onto a different config); otherwise returns them untouched so the
1769/// upsert reuses the existing rows.
1770fn rekey_peer_or_self_review_questions(
1771    questions: Vec<CmsPeerOrSelfReviewQuestion>,
1772    source_config_id: Uuid,
1773    target_config_id: Uuid,
1774) -> Vec<CmsPeerOrSelfReviewQuestion> {
1775    if source_config_id == target_config_id {
1776        return questions;
1777    }
1778    questions
1779        .into_iter()
1780        .map(|mut question| {
1781            question.id = Uuid::new_v4();
1782            question.peer_or_self_review_config_id = target_config_id;
1783            question
1784        })
1785        .collect()
1786}
1787
1788/// Re-keys a custom peer-review config and its questions onto `new_exercise_id` with config and
1789/// question ids from `new_id`, producing an independent copy for a duplicated exercise so it does
1790/// not collapse onto the original exercise's config (#148). All `questions` must belong to
1791/// `config`.
1792pub fn fork_peer_or_self_review_config(
1793    config: &mut CmsPeerOrSelfReviewConfig,
1794    questions: &mut [CmsPeerOrSelfReviewQuestion],
1795    new_exercise_id: Uuid,
1796    mut new_id: impl FnMut() -> Uuid,
1797) {
1798    let new_config_id = new_id();
1799    config.id = new_config_id;
1800    config.exercise_id = Some(new_exercise_id);
1801    for question in questions.iter_mut() {
1802        question.id = new_id();
1803        question.peer_or_self_review_config_id = new_config_id;
1804    }
1805}
1806
1807#[derive(Debug, Clone)]
1808pub struct PageUpdateArgs {
1809    pub page_id: Uuid,
1810    pub author: HistoryAuthor,
1811    pub cms_page_update: CmsPageUpdate,
1812    pub retain_ids: bool,
1813    pub history_change_reason: HistoryChangeReason,
1814    pub is_exam_page: bool,
1815}
1816
1817pub async fn update_page(
1818    conn: &mut PgConnection,
1819    page_update: PageUpdateArgs,
1820    spec_fetcher: impl SpecFetcher,
1821    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
1822) -> ModelResult<ContentManagementPage> {
1823    let mut cms_page_update = page_update.cms_page_update;
1824    // Give a fresh id to any exercise duplicated within this page so duplicates don't collapse into
1825    // a single backend exercise (#148). Must run before validation and the upsert below.
1826    let original_exercise_id_by_new_id = cms_page_update.regenerate_duplicate_exercise_ids();
1827    cms_page_update.validate_exercise_data()?;
1828
1829    for exercise in cms_page_update.exercises.iter_mut() {
1830        if let Some(peer_or_self_review_questions) = exercise.peer_or_self_review_questions.as_mut()
1831        {
1832            normalize_cms_peer_or_self_review_questions(peer_or_self_review_questions);
1833        }
1834    }
1835
1836    let content = suffix_duplicate_heading_anchors(replace_duplicate_client_ids(
1837        cms_page_update.content.clone(),
1838    ));
1839
1840    if !page_update.is_exam_page
1841        && cms_page_update.chapter_id.is_none()
1842        && contains_blocks_not_allowed_in_top_level_pages(&content)
1843    {
1844        return Err(model_err!(Generic, "Top level non-exam pages cannot contain exercises, exercise tasks or list of exercises in the chapter".to_string()));
1845    }
1846
1847    let mut tx = conn.begin().await?;
1848
1849    set_paths(&mut tx, &[(page_update.page_id, &cms_page_update.url_path)]).await?;
1850    let page = sqlx::query_as!(
1851        Page,
1852        r"
1853UPDATE pages
1854SET content = $2,
1855  title = $3,
1856  chapter_id = $4,
1857  hidden = $5
1858WHERE id = $1
1859RETURNING id,
1860  created_at,
1861  updated_at,
1862  course_id,
1863  exam_id,
1864  chapter_id,
1865  url_path,
1866  title,
1867  deleted_at,
1868  content,
1869  order_number,
1870  copied_from,
1871  pages.hidden,
1872  pages.page_language_group_id
1873        ",
1874        page_update.page_id,
1875        serde_json::to_value(&content)?,
1876        cms_page_update.title.trim(),
1877        cms_page_update.chapter_id,
1878        cms_page_update.hidden
1879    )
1880    .fetch_one(&mut *tx)
1881    .await?;
1882
1883    // Fetch the existing custom peer-review configs/questions before deleting exercises below. Both
1884    // queries join `exercises` with `deleted_at IS NULL`, so they must run before
1885    // `delete_exercises_by_page_id` or they would return nothing. They are used as a fallback to
1886    // preserve custom peer-review settings for exercises whose payload does not explicitly include
1887    // them (e.g. an inconsistent editor state) (#129).
1888    let existing_peer_or_self_review_configs_by_exercise_id =
1889        crate::peer_or_self_review_configs::get_peer_reviews_by_page_ids(&mut tx, &[page.id])
1890            .await?
1891            .into_iter()
1892            .filter_map(|pr| pr.exercise_id.map(|id| (id, pr)))
1893            .collect::<HashMap<Uuid, CmsPeerOrSelfReviewConfig>>();
1894    let existing_peer_or_self_review_questions_by_config_id =
1895        crate::peer_or_self_review_questions::get_by_page_ids(&mut tx, &[page.id])
1896            .await?
1897            .into_iter()
1898            .into_group_map_by(|prq| prq.peer_or_self_review_config_id);
1899
1900    // Exercises
1901    let existing_exercise_ids =
1902        crate::exercises::delete_exercises_by_page_id(&mut tx, page.id).await?;
1903    let remapped_exercises = upsert_exercises(
1904        &mut tx,
1905        &page,
1906        &existing_exercise_ids,
1907        &cms_page_update.exercises,
1908        page_update.retain_ids,
1909    )
1910    .await?;
1911
1912    // Exercise slides
1913    let existing_exercise_slide_ids =
1914        crate::exercise_slides::delete_exercise_slides_by_exercise_ids(
1915            &mut tx,
1916            &existing_exercise_ids,
1917        )
1918        .await?;
1919    let remapped_exercise_slides = upsert_exercise_slides(
1920        &mut tx,
1921        &remapped_exercises,
1922        &existing_exercise_slide_ids,
1923        &cms_page_update.exercise_slides,
1924        page_update.retain_ids,
1925    )
1926    .await?;
1927
1928    // Peer reviews
1929    let existing_peer_or_self_review_config_ids =
1930        crate::peer_or_self_review_configs::delete_peer_reviews_by_exrcise_ids(
1931            &mut tx,
1932            &existing_exercise_ids,
1933        )
1934        .await?;
1935
1936    // Configs present in this save's payload, keyed by exercise id. Lets a regenerated within-page
1937    // duplicate that omits its own config recover its original's *current* config — preferring the
1938    // edit the user just made over the pre-save DB snapshot (#148).
1939    let payload_peer_or_self_review_by_exercise_id: HashMap<
1940        Uuid,
1941        (
1942            CmsPeerOrSelfReviewConfig,
1943            Option<Vec<CmsPeerOrSelfReviewQuestion>>,
1944        ),
1945    > = cms_page_update
1946        .exercises
1947        .iter()
1948        .filter(|e| !e.use_course_default_peer_or_self_review_config)
1949        .filter_map(|e| {
1950            e.peer_or_self_review_config.as_ref().map(|config| {
1951                (
1952                    e.id,
1953                    (config.clone(), e.peer_or_self_review_questions.clone()),
1954                )
1955            })
1956        })
1957        .collect();
1958
1959    // Recovers the custom config + questions stored/sent for `exercise_id`, preferring this save's
1960    // payload over the pre-save DB snapshot and resolving omitted questions from the snapshot.
1961    let recover_peer_or_self_review = |exercise_id: Uuid| -> Option<(
1962        CmsPeerOrSelfReviewConfig,
1963        Vec<CmsPeerOrSelfReviewQuestion>,
1964    )> {
1965        if let Some((config, questions)) =
1966            payload_peer_or_self_review_by_exercise_id.get(&exercise_id)
1967        {
1968            let questions = match questions {
1969                Some(questions) => questions.clone(),
1970                None => existing_peer_or_self_review_questions_by_config_id
1971                    .get(&config.id)
1972                    .cloned()
1973                    .unwrap_or_default(),
1974            };
1975            return Some((config.clone(), questions));
1976        }
1977        let config = existing_peer_or_self_review_configs_by_exercise_id
1978            .get(&exercise_id)?
1979            .clone();
1980        let questions = existing_peer_or_self_review_questions_by_config_id
1981            .get(&config.id)
1982            .cloned()
1983            .unwrap_or_default();
1984        Some((config, questions))
1985    };
1986
1987    let (peer_or_self_review_configs, peer_or_self_review_questions) = cms_page_update
1988        .exercises
1989        .into_iter()
1990        .filter(|e| !e.use_course_default_peer_or_self_review_config)
1991        .filter_map(|e| {
1992            let exercise_id = e.id;
1993            // For a within-page duplicate that was regenerated a fresh id (#148), its settings still
1994            // live under the original exercise/config id; otherwise this is the exercise's own id.
1995            let original_exercise_id = original_exercise_id_by_new_id.get(&exercise_id).copied();
1996            // Prefer the config/questions sent in the payload. If the payload omits them (e.g. an
1997            // inconsistent editor state where a custom exercise is saved without its config or
1998            // questions), fall back to the existing custom settings so they are not lost (#129). An
1999            // exercise switching to the course default is filtered out above, so its config is
2000            // correctly left deleted.
2001            match e.peer_or_self_review_config {
2002                Some(config) => {
2003                    // The config is present, but the questions may be omitted. `None` means "keep the
2004                    // existing questions"; `Some(vec![])` is an explicit "remove all questions". The
2005                    // existing questions live under the source config id — this exercise's own for a
2006                    // normal save, or the original's for a regenerated duplicate whose config id was
2007                    // freshly minted, so they are re-keyed onto the new config id.
2008                    let questions = match e.peer_or_self_review_questions {
2009                        Some(questions) => questions,
2010                        None => {
2011                            recover_peer_or_self_review(original_exercise_id.unwrap_or(exercise_id))
2012                                .map(|(source_config, source_questions)| {
2013                                    rekey_peer_or_self_review_questions(
2014                                        source_questions,
2015                                        source_config.id,
2016                                        config.id,
2017                                    )
2018                                })
2019                                .unwrap_or_default()
2020                        }
2021                    };
2022                    Some((config, questions))
2023                }
2024                None => {
2025                    // The whole config is omitted. Recover this exercise's own stored config (#129);
2026                    // if it is a regenerated duplicate with nothing of its own, recover the original's
2027                    // current config and fork it onto this duplicate with fresh ids (#148).
2028                    if let Some((config, questions)) = recover_peer_or_self_review(exercise_id) {
2029                        return Some((config, questions));
2030                    }
2031                    let (mut config, mut questions) =
2032                        original_exercise_id.and_then(&recover_peer_or_self_review)?;
2033                    fork_peer_or_self_review_config(
2034                        &mut config,
2035                        &mut questions,
2036                        exercise_id,
2037                        Uuid::new_v4,
2038                    );
2039                    Some((config, questions))
2040                }
2041            }
2042        })
2043        .fold((vec![], vec![]), |(mut a, mut b), (pr, prq)| {
2044            a.push(pr);
2045            b.extend(prq);
2046            (a, b)
2047        });
2048
2049    let remapped_peer_or_self_review_configs = upsert_peer_or_self_review_configs(
2050        &mut tx,
2051        &existing_peer_or_self_review_config_ids,
2052        &peer_or_self_review_configs,
2053        &remapped_exercises,
2054        page_update.retain_ids,
2055    )
2056    .await?;
2057
2058    // Peer review questions
2059    let existing_peer_or_self_review_questions =
2060        crate::peer_or_self_review_questions::delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
2061            &mut tx,
2062            &existing_peer_or_self_review_config_ids,
2063        )
2064        .await?;
2065
2066    let remapped_peer_or_self_review_questions = upsert_peer_or_self_review_questions(
2067        &mut tx,
2068        &existing_peer_or_self_review_questions,
2069        &peer_or_self_review_questions,
2070        &remapped_peer_or_self_review_configs,
2071        page_update.retain_ids,
2072    )
2073    .await?;
2074
2075    // Set as deleted and get existing specs
2076    let existing_exercise_task_specs = sqlx::query_as!(
2077        ExerciseTaskIdAndSpec,
2078        "
2079UPDATE exercise_tasks
2080SET deleted_at = now()
2081WHERE exercise_slide_id = ANY($1)
2082AND deleted_at IS NULL
2083RETURNING id,
2084  private_spec,
2085  public_spec,
2086  model_solution_spec;
2087        ",
2088        &existing_exercise_slide_ids,
2089    )
2090    .fetch_all(&mut *tx)
2091    .await?;
2092    let final_tasks = upsert_exercise_tasks(
2093        &mut tx,
2094        &remapped_exercise_slides,
2095        &existing_exercise_task_specs,
2096        &cms_page_update.exercise_tasks,
2097        page_update.retain_ids,
2098        &spec_fetcher,
2099        fetch_service_info,
2100    )
2101    .await?;
2102
2103    // Now, we might have changed some of the exercise ids and need to do the same changes in the page content as well
2104    let new_content = headless_lms_utils::document_schema_processor::remap_ids_in_content(
2105        &page.content,
2106        remapped_exercises
2107            .iter()
2108            .map(|(id, e)| (*id, e.id))
2109            .collect::<HashMap<Uuid, Uuid>>(),
2110    )?;
2111
2112    let page = sqlx::query_as!(
2113        Page,
2114        "
2115UPDATE pages
2116SET content = $1
2117WHERE id = $2
2118RETURNING id,
2119  created_at,
2120  updated_at,
2121  course_id,
2122  exam_id,
2123  chapter_id,
2124  url_path,
2125  title,
2126  deleted_at,
2127  content,
2128  order_number,
2129  copied_from,
2130  hidden,
2131  page_language_group_id
2132        ",
2133        new_content,
2134        page.id
2135    )
2136    .fetch_one(&mut *tx)
2137    .await?;
2138
2139    let x = remapped_exercises.into_values().collect::<Vec<_>>();
2140    let final_exercises = x
2141        .iter()
2142        .map(|e| {
2143            let peer_or_self_review_config = remapped_peer_or_self_review_configs
2144                .values()
2145                .find(|prc| prc.exercise_id == Some(e.id));
2146            if let Some(prc) = peer_or_self_review_config {
2147                let peer_or_self_review_questions = remapped_peer_or_self_review_questions
2148                    .values()
2149                    .filter(|prq| prq.peer_or_self_review_config_id == prc.id)
2150                    .cloned()
2151                    .collect::<Vec<_>>();
2152                return CmsPageExercise::from_exercise_and_peer_review_data(
2153                    e.clone(),
2154                    Some(prc.clone()),
2155                    Some(peer_or_self_review_questions),
2156                );
2157            }
2158            CmsPageExercise::from_exercise_and_peer_review_data(e.clone(), None, None)
2159        })
2160        .collect();
2161    let final_slides: Vec<CmsPageExerciseSlide> = remapped_exercise_slides.into_values().collect();
2162    let final_peer_reviews: Vec<CmsPeerOrSelfReviewConfig> =
2163        remapped_peer_or_self_review_configs.into_values().collect();
2164    let final_peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion> =
2165        remapped_peer_or_self_review_questions
2166            .into_values()
2167            .collect();
2168    let history_content = PageHistoryContent {
2169        content: page.content.clone(),
2170        exercises: final_exercises,
2171        exercise_slides: final_slides,
2172        exercise_tasks: final_tasks,
2173        peer_or_self_review_configs: final_peer_reviews,
2174        peer_or_self_review_questions: final_peer_or_self_review_questions,
2175    };
2176    crate::page_history::insert(
2177        &mut tx,
2178        PKeyPolicy::Generate,
2179        page_update.page_id,
2180        &cms_page_update.title,
2181        &history_content,
2182        page_update.history_change_reason,
2183        page_update.author,
2184        None,
2185    )
2186    .await?;
2187    let organization_id = get_organization_id(&mut tx, page.id).await?;
2188
2189    tx.commit().await?;
2190
2191    Ok(ContentManagementPage {
2192        page,
2193        exercises: history_content.exercises,
2194        exercise_slides: history_content.exercise_slides,
2195        exercise_tasks: history_content.exercise_tasks,
2196        peer_or_self_review_configs: history_content.peer_or_self_review_configs,
2197        peer_or_self_review_questions: history_content.peer_or_self_review_questions,
2198        organization_id,
2199    })
2200}
2201
2202/// With `expected_updated_at` set, refuses with [ModelErrorType::StaleVersion] unless the page row
2203/// still has that `updated_at`. The check runs under the page row lock.
2204pub async fn update_by_id_in_parent_context(
2205    conn: &mut PgConnection,
2206    page_update: PageUpdateArgs,
2207    expected_course_id: Option<Uuid>,
2208    expected_exam_id: Option<Uuid>,
2209    expected_updated_at: Option<DateTime<Utc>>,
2210    spec_fetcher: impl SpecFetcher,
2211    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2212) -> ModelResult<ContentManagementPage> {
2213    let mut tx = conn.begin().await?;
2214    let current_updated_at = sqlx::query_scalar!(
2215        r#"
2216SELECT p.updated_at
2217FROM pages p
2218LEFT JOIN chapters c
2219  ON c.id = $4
2220WHERE p.id = $1
2221  AND p.deleted_at IS NULL
2222  AND (p.course_id = $2 OR p.exam_id = $3)
2223  AND (
2224    $4::uuid IS NULL
2225    OR (
2226      p.course_id IS NOT NULL
2227      AND c.id = $4
2228      AND c.course_id = p.course_id
2229      AND c.deleted_at IS NULL
2230    )
2231  )
2232 FOR UPDATE OF p
2233        "#,
2234        page_update.page_id,
2235        expected_course_id,
2236        expected_exam_id,
2237        page_update.cms_page_update.chapter_id
2238    )
2239    .fetch_one(&mut *tx)
2240    .await?;
2241
2242    ensure_version(
2243        Some(current_updated_at),
2244        expected_updated_at,
2245        "The page was changed after it was opened.",
2246    )?;
2247
2248    let updated = update_page(&mut tx, page_update, spec_fetcher, fetch_service_info).await?;
2249    tx.commit().await?;
2250    Ok(updated)
2251}
2252
2253/// Where a saved task already existing means that saving it adds nothing.
2254#[derive(Debug, Clone, Copy)]
2255pub enum ExistingTasks {
2256    /// On the saved page; `None` for a page that does not exist yet.
2257    OnPage(Option<Uuid>),
2258    /// Anywhere in the course, for saves that move exercises between pages.
2259    InCourse(Uuid),
2260}
2261
2262/// Names of the exercise services that only global admins can add which saving `exercise_tasks`
2263/// would add: a task counts as added unless it is the first submitted task with its id and a task
2264/// with that id and type already exists where `existing` says.
2265pub async fn get_global_admin_only_exercise_service_names_added_by_tasks(
2266    conn: &mut PgConnection,
2267    existing: ExistingTasks,
2268    exercise_tasks: &[CmsPageExerciseTask],
2269) -> ModelResult<Vec<String>> {
2270    let (page_id, course_id) = match existing {
2271        ExistingTasks::OnPage(page_id) => (page_id, None),
2272        ExistingTasks::InCourse(course_id) => (None, Some(course_id)),
2273    };
2274    let task_ids: Vec<Uuid> = exercise_tasks.iter().map(|task| task.id).collect();
2275    let exercise_types: Vec<String> = exercise_tasks
2276        .iter()
2277        .map(|task| task.exercise_type.clone())
2278        .collect();
2279    let names = sqlx::query_scalar!(
2280        r#"
2281WITH saved_task AS (
2282  SELECT id,
2283    exercise_type,
2284    row_number() OVER (
2285      PARTITION BY id
2286      ORDER BY submitted_order
2287    ) AS occurrence
2288  FROM UNNEST($2::uuid [], $3::text []) WITH ORDINALITY AS t(id, exercise_type, submitted_order)
2289)
2290SELECT DISTINCT es.name
2291FROM saved_task
2292  JOIN exercise_services es ON es.slug = saved_task.exercise_type
2293WHERE es.is_global_admin_only
2294  AND es.deleted_at IS NULL
2295  -- update_page gives repeated task ids fresh ids, inserting them as new tasks
2296  AND (
2297    saved_task.occurrence > 1
2298    OR NOT EXISTS (
2299      SELECT 1
2300      FROM exercise_tasks et
2301        JOIN exercise_slides slides ON slides.id = et.exercise_slide_id
2302        JOIN exercises e ON e.id = slides.exercise_id
2303      WHERE et.id = saved_task.id
2304        AND et.exercise_type = saved_task.exercise_type
2305        AND et.deleted_at IS NULL
2306        AND slides.deleted_at IS NULL
2307        AND e.deleted_at IS NULL
2308        AND (
2309          e.page_id = $1
2310          OR e.course_id = $4
2311        )
2312    )
2313  )
2314ORDER BY es.name
2315        "#,
2316        page_id,
2317        &task_ids,
2318        &exercise_types,
2319        course_id,
2320    )
2321    .fetch_all(conn)
2322    .await?;
2323    Ok(names)
2324}
2325
2326/// Remaps ids from updates to exercises that may have their ids regenerated.
2327async fn upsert_exercises(
2328    conn: &mut PgConnection,
2329    page: &Page,
2330    existing_exercise_ids: &[Uuid],
2331    exercise_updates: &[CmsPageExercise],
2332    retain_exercise_ids: bool,
2333) -> ModelResult<HashMap<Uuid, Exercise>> {
2334    let mut remapped_exercises = HashMap::new();
2335    for exercise_update in exercise_updates.iter() {
2336        let exercise_exists = existing_exercise_ids.contains(&exercise_update.id);
2337        let safe_for_db_exercise_id = if retain_exercise_ids || exercise_exists {
2338            exercise_update.id
2339        } else {
2340            Uuid::new_v4()
2341        };
2342
2343        // check if exercise exits
2344        let db_exercise = crate::exercises::get_by_id(&mut *conn, safe_for_db_exercise_id)
2345            .await
2346            .optional()?;
2347
2348        let mut exercise_language_group_id = None;
2349
2350        if let Some(db_exercise) = db_exercise {
2351            exercise_language_group_id = db_exercise.exercise_language_group_id;
2352        }
2353        if let (None, Some(course_id)) = (exercise_language_group_id, page.course_id) {
2354            let course = crate::courses::get_course(&mut *conn, course_id).await?;
2355
2356            exercise_language_group_id = Some(
2357                crate::exercise_language_groups::insert(
2358                    &mut *conn,
2359                    PKeyPolicy::Generate,
2360                    course.course_language_group_id,
2361                )
2362                .await?,
2363            );
2364        }
2365
2366        let exercise = sqlx::query_as!(
2367            Exercise,
2368            "
2369INSERT INTO exercises(
2370    id,
2371    course_id,
2372    name,
2373    order_number,
2374    page_id,
2375    chapter_id,
2376    exam_id,
2377    score_maximum,
2378    max_tries_per_slide,
2379    limit_number_of_tries,
2380    deadline,
2381    needs_peer_review,
2382    needs_self_review,
2383    use_course_default_peer_or_self_review_config,
2384    exercise_language_group_id,
2385    teacher_reviews_answer_after_locking
2386  )
2387VALUES (
2388    $1,
2389    $2,
2390    $3,
2391    $4,
2392    $5,
2393    $6,
2394    $7,
2395    $8,
2396    $9,
2397    $10,
2398    $11,
2399    $12,
2400    $13,
2401    $14,
2402    $15,
2403    $16
2404  ) ON CONFLICT (id) DO
2405UPDATE
2406SET course_id = $2,
2407  name = $3,
2408  order_number = $4,
2409  page_id = $5,
2410  chapter_id = $6,
2411  exam_id = $7,
2412  score_maximum = $8,
2413  max_tries_per_slide = $9,
2414  limit_number_of_tries = $10,
2415  deadline = $11,
2416  needs_peer_review = $12,
2417  needs_self_review = $13,
2418  use_course_default_peer_or_self_review_config = $14,
2419  exercise_language_group_id = $15,
2420  teacher_reviews_answer_after_locking = $16,
2421  deleted_at = NULL
2422RETURNING *;
2423            ",
2424            safe_for_db_exercise_id,
2425            page.course_id,
2426            exercise_update.name,
2427            exercise_update.order_number,
2428            page.id,
2429            page.chapter_id,
2430            page.exam_id,
2431            exercise_update.score_maximum,
2432            exercise_update.max_tries_per_slide,
2433            exercise_update.limit_number_of_tries,
2434            exercise_update.deadline,
2435            exercise_update.needs_peer_review,
2436            exercise_update.needs_self_review,
2437            exercise_update.use_course_default_peer_or_self_review_config,
2438            exercise_language_group_id,
2439            exercise_update.teacher_reviews_answer_after_locking,
2440        )
2441        .fetch_one(&mut *conn)
2442        .await?;
2443
2444        remapped_exercises.insert(exercise_update.id, exercise);
2445    }
2446    Ok(remapped_exercises)
2447}
2448
2449/// Remaps ids from updates to exercise slides that may have their ids changed.
2450async fn upsert_exercise_slides(
2451    conn: &mut PgConnection,
2452    remapped_exercises: &HashMap<Uuid, Exercise>,
2453    existing_slide_ids: &[Uuid],
2454    slide_updates: &[CmsPageExerciseSlide],
2455    retain_exercise_ids: bool,
2456) -> ModelResult<HashMap<Uuid, CmsPageExerciseSlide>> {
2457    let mut remapped_exercise_slides = HashMap::new();
2458    for slide_update in slide_updates.iter() {
2459        let slide_exists = existing_slide_ids.contains(&slide_update.id);
2460        let safe_for_db_slide_id = if retain_exercise_ids || slide_exists {
2461            slide_update.id
2462        } else {
2463            Uuid::new_v4()
2464        };
2465        let safe_for_db_exercise_id = remapped_exercises
2466            .get(&slide_update.exercise_id)
2467            .ok_or_else(|| {
2468                model_err!(
2469                    InvalidRequest,
2470                    "Illegal exercise id for exercise slide.".to_string()
2471                )
2472            })?
2473            .id;
2474
2475        let exercise_slide = sqlx::query_as!(
2476            CmsPageExerciseSlide,
2477            "
2478INSERT INTO exercise_slides (id, exercise_id, order_number)
2479VALUES ($1, $2, $3) ON CONFLICT (id) DO
2480UPDATE
2481SET exercise_id = $2,
2482  order_number = $3,
2483  deleted_at = NULL
2484RETURNING id,
2485  exercise_id,
2486  order_number;
2487            ",
2488            safe_for_db_slide_id,
2489            safe_for_db_exercise_id,
2490            slide_update.order_number,
2491        )
2492        .fetch_one(&mut *conn)
2493        .await?;
2494
2495        remapped_exercise_slides.insert(slide_update.id, exercise_slide);
2496    }
2497    Ok(remapped_exercise_slides)
2498}
2499
2500/// Remaps ids from updates to exercise tasks that may have their ids changed.
2501async fn upsert_exercise_tasks(
2502    conn: &mut PgConnection,
2503    remapped_slides: &HashMap<Uuid, CmsPageExerciseSlide>,
2504    existing_task_specs: &[ExerciseTaskIdAndSpec],
2505    task_updates: &[CmsPageExerciseTask],
2506    retain_exercise_ids: bool,
2507    spec_fetcher: impl SpecFetcher,
2508    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2509) -> ModelResult<Vec<CmsPageExerciseTask>> {
2510    // For generating public specs for exercises.
2511    let exercise_types: Vec<String> = task_updates
2512        .iter()
2513        .map(|task| task.exercise_type.clone())
2514        .unique()
2515        .collect();
2516    let exercise_service_hashmap = exercise_service_info::get_selected_exercise_services_by_type(
2517        &mut *conn,
2518        &exercise_types,
2519        fetch_service_info,
2520    )
2521    .await?;
2522    let public_spec_urls_by_exercise_type = exercise_service_hashmap
2523        .iter()
2524        .map(|(key, (service, info))| Ok((key, get_internal_public_spec_url(service, info)?)))
2525        .collect::<ModelResult<HashMap<&String, Url>>>()?;
2526    let model_solution_urls_by_exercise_type = exercise_service_hashmap
2527        .iter()
2528        .map(|(key, (service, info))| Ok((key, get_model_solution_url(service, info)?)))
2529        .collect::<ModelResult<HashMap<&String, Url>>>()?;
2530
2531    let mut remapped_exercise_tasks = Vec::new();
2532    for task_update in task_updates.iter() {
2533        let existing_exercise_task = existing_task_specs.iter().find(|o| o.id == task_update.id);
2534        let safe_for_db_exercise_task_id = match existing_exercise_task {
2535            Some(_) => task_update.id,
2536            _ if retain_exercise_ids => task_update.id,
2537            None => Uuid::new_v4(),
2538        };
2539
2540        let task_exists = existing_task_specs
2541            .iter()
2542            .any(|task| task.id == task_update.id);
2543        let safe_for_db_task_id = if retain_exercise_ids || task_exists {
2544            task_update.id
2545        } else {
2546            Uuid::new_v4()
2547        };
2548        let normalized_task = NormalizedCmsExerciseTask {
2549            id: safe_for_db_task_id,
2550            assignment: task_update.assignment.clone(),
2551            exercise_type: task_update.exercise_type.clone(),
2552            private_spec: task_update.private_spec.clone(),
2553        };
2554        let declares_spec_files = exercise_service_hashmap
2555            .get(&task_update.exercise_type)
2556            .is_some_and(|(_service, info)| info.declares_spec_files);
2557        let model_solution = fetch_derived_spec(
2558            existing_exercise_task,
2559            &normalized_task,
2560            &model_solution_urls_by_exercise_type,
2561            &spec_fetcher,
2562            existing_exercise_task.and_then(|value| value.model_solution_spec.clone()),
2563            task_update.id,
2564            declares_spec_files,
2565        )
2566        .await?;
2567        let public = fetch_derived_spec(
2568            existing_exercise_task,
2569            &normalized_task,
2570            &public_spec_urls_by_exercise_type,
2571            &spec_fetcher,
2572            existing_exercise_task.and_then(|value| value.public_spec.clone()),
2573            task_update.id,
2574            declares_spec_files,
2575        )
2576        .await?;
2577        let model_solution_spec = model_solution.spec;
2578        let public_spec = public.spec;
2579        let safe_for_db_exercise_slide_id = remapped_slides
2580            .get(&task_update.exercise_slide_id)
2581            .ok_or_else(|| {
2582                model_err!(
2583                    InvalidRequest,
2584                    "Illegal exercise slide id for exercise task.".to_string()
2585                )
2586            })?
2587            .id;
2588
2589        // Upsert
2590        let upserted = sqlx::query!(
2591            "
2592INSERT INTO exercise_tasks(
2593    id,
2594    exercise_slide_id,
2595    exercise_type,
2596    assignment,
2597    public_spec,
2598    private_spec,
2599    model_solution_spec,
2600    order_number
2601  )
2602VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO
2603UPDATE
2604SET exercise_slide_id = $2,
2605  exercise_type = $3,
2606  assignment = $4,
2607  public_spec = $5,
2608  private_spec = $6,
2609  model_solution_spec = $7,
2610  order_number = $8,
2611  deleted_at = NULL
2612RETURNING id,
2613  exercise_slide_id,
2614  assignment,
2615  exercise_type,
2616  private_spec,
2617  order_number
2618                ",
2619            safe_for_db_exercise_task_id,
2620            safe_for_db_exercise_slide_id,
2621            task_update.exercise_type,
2622            task_update.assignment,
2623            public_spec,
2624            task_update.private_spec,
2625            model_solution_spec,
2626            task_update.order_number,
2627        )
2628        .fetch_one(&mut *conn)
2629        .await?;
2630        // The exercise service declares these; the host cannot read them out of the specs it just
2631        // stored. Rewritten in full per kind, so dropping a file from a spec releases it.
2632        crate::exercise_task_spec_files::replace_for_exercise_task(
2633            &mut *conn,
2634            upserted.id,
2635            crate::exercise_task_spec_files::SpecKind::Private,
2636            &task_update.private_spec_files,
2637        )
2638        .await?;
2639        // Only when this save actually re-derived and the service declared: a skipped derivation
2640        // leaves the previous spec in place, so its declarations must stay in place too.
2641        if let Some(declared) = public.declared_files.as_ref() {
2642            crate::exercise_task_spec_files::replace_for_exercise_task(
2643                &mut *conn,
2644                upserted.id,
2645                crate::exercise_task_spec_files::SpecKind::Public,
2646                declared,
2647            )
2648            .await?;
2649        }
2650        if let Some(declared) = model_solution.declared_files.as_ref() {
2651            crate::exercise_task_spec_files::replace_for_exercise_task(
2652                &mut *conn,
2653                upserted.id,
2654                crate::exercise_task_spec_files::SpecKind::ModelSolution,
2655                declared,
2656            )
2657            .await?;
2658        }
2659        remapped_exercise_tasks.push(CmsPageExerciseTask {
2660            id: upserted.id,
2661            exercise_slide_id: upserted.exercise_slide_id,
2662            assignment: upserted.assignment,
2663            exercise_type: upserted.exercise_type,
2664            private_spec: upserted.private_spec,
2665            order_number: upserted.order_number,
2666            private_spec_files: task_update.private_spec_files.clone(),
2667        })
2668    }
2669    Ok(remapped_exercise_tasks)
2670}
2671
2672pub async fn upsert_peer_or_self_review_configs(
2673    conn: &mut PgConnection,
2674    existing_peer_reviews: &[Uuid],
2675    peer_reviews: &[CmsPeerOrSelfReviewConfig],
2676    remapped_exercises: &HashMap<Uuid, Exercise>,
2677    retain_ids: bool,
2678) -> ModelResult<HashMap<Uuid, CmsPeerOrSelfReviewConfig>> {
2679    if peer_reviews.is_empty() {
2680        Ok(HashMap::new())
2681    } else {
2682        let mut new_peer_or_self_review_config_id_to_old_id = HashMap::new();
2683
2684        let mut sql: QueryBuilder<Postgres> = QueryBuilder::new(
2685            "INSERT INTO peer_or_self_review_configs (
2686        id,
2687        course_id,
2688        exercise_id,
2689        peer_reviews_to_give,
2690        peer_reviews_to_receive,
2691        processing_strategy,
2692        accepting_threshold,
2693        points_are_all_or_nothing,
2694        reset_answer_if_zero_points_from_review,
2695        review_instructions,
2696        deleted_at
2697      ) ",
2698        );
2699
2700        // No way to return from push_values, we can use this to detect an error after the push_values
2701        let mut illegal_exercise_id = None;
2702
2703        sql.push_values(peer_reviews.iter().take(1000), |mut x, pr| {
2704            let peer_review_exists = existing_peer_reviews.contains(&pr.id);
2705            let safe_for_db_peer_or_self_review_config_id = if retain_ids || peer_review_exists {
2706                pr.id
2707            } else {
2708                Uuid::new_v4()
2709            };
2710            new_peer_or_self_review_config_id_to_old_id
2711                .insert(safe_for_db_peer_or_self_review_config_id, pr.id);
2712
2713            let exercise = pr.exercise_id.and_then(|id| {
2714                let res = remapped_exercises.get(&id);
2715                if res.is_none() {
2716                    error!("Illegal exercise id {:?}", id);
2717                    illegal_exercise_id = Some(id);
2718                }
2719                res
2720            });
2721            // The client's course_id is stale when the exercise block was pasted from another
2722            // course, and course copying breaks if it disagrees with the exercise's course.
2723            let safe_for_db_course_id = exercise.and_then(|e| e.course_id).unwrap_or(pr.course_id);
2724
2725            x.push_bind(safe_for_db_peer_or_self_review_config_id)
2726                .push_bind(safe_for_db_course_id)
2727                .push_bind(exercise.map(|e| e.id))
2728                .push_bind(pr.peer_reviews_to_give)
2729                .push_bind(pr.peer_reviews_to_receive)
2730                .push_bind(pr.processing_strategy)
2731                .push_bind(pr.accepting_threshold)
2732                .push_bind(pr.points_are_all_or_nothing)
2733                .push_bind(pr.reset_answer_if_zero_points_from_review)
2734                .push_bind(pr.review_instructions.clone())
2735                .push("NULL");
2736        });
2737
2738        if let Some(illegal_exercise_id) = illegal_exercise_id {
2739            return Err(model_err!(
2740                InvalidRequest,
2741                format!("Illegal exercise id {:?}", illegal_exercise_id)
2742            ));
2743        }
2744
2745        sql.push(
2746            " ON CONFLICT (id) DO
2747UPDATE
2748SET course_id = excluded.course_id,
2749  exercise_id = excluded.exercise_id,
2750  peer_reviews_to_give = excluded.peer_reviews_to_give,
2751  peer_reviews_to_receive = excluded.peer_reviews_to_receive,
2752  processing_strategy = excluded.processing_strategy,
2753  accepting_threshold = excluded.accepting_threshold,
2754  points_are_all_or_nothing = excluded.points_are_all_or_nothing,
2755  reset_answer_if_zero_points_from_review = excluded.reset_answer_if_zero_points_from_review,
2756  review_instructions = excluded.review_instructions,
2757  deleted_at = NULL
2758RETURNING id;
2759",
2760        );
2761
2762        let ids = sql
2763            .build()
2764            .fetch_all(&mut *conn)
2765            .await?
2766            .iter()
2767            .map(|x| x.get(0))
2768            .collect::<Vec<_>>();
2769
2770        let prs = sqlx::query_as!(
2771            CmsPeerOrSelfReviewConfig,
2772            r#"
2773SELECT id as "id!",
2774  course_id as "course_id!",
2775  exercise_id,
2776  peer_reviews_to_give as "peer_reviews_to_give!",
2777  peer_reviews_to_receive as "peer_reviews_to_receive!",
2778  processing_strategy AS "processing_strategy!",
2779  accepting_threshold "accepting_threshold!",
2780  points_are_all_or_nothing "points_are_all_or_nothing!",
2781  reset_answer_if_zero_points_from_review,
2782  review_instructions
2783FROM peer_or_self_review_configs
2784WHERE id IN (
2785    SELECT UNNEST($1::uuid [])
2786  )
2787  AND deleted_at IS NULL;
2788    "#,
2789            &ids
2790        )
2791        .fetch_all(&mut *conn)
2792        .await?;
2793
2794        let mut remapped_peer_reviews = HashMap::new();
2795
2796        for pr in prs {
2797            let old_id = new_peer_or_self_review_config_id_to_old_id
2798                .get(&pr.id)
2799                .ok_or_else(|| {
2800                    model_err!(Generic, "Inserted peer reviews not found".to_string())
2801                })?;
2802            remapped_peer_reviews.insert(*old_id, pr);
2803        }
2804
2805        Ok(remapped_peer_reviews)
2806    }
2807}
2808
2809pub async fn upsert_peer_or_self_review_questions(
2810    conn: &mut PgConnection,
2811    existing_peer_or_self_review_questions: &[Uuid],
2812    peer_or_self_review_questions: &[CmsPeerOrSelfReviewQuestion],
2813    remapped_peer_or_self_review_config_ids: &HashMap<Uuid, CmsPeerOrSelfReviewConfig>,
2814    retain_ids: bool,
2815) -> ModelResult<HashMap<Uuid, CmsPeerOrSelfReviewQuestion>> {
2816    if peer_or_self_review_questions.is_empty() {
2817        Ok(HashMap::new())
2818    } else {
2819        let mut new_peer_or_self_review_question_id_to_old_id = HashMap::new();
2820
2821        let mut sql: QueryBuilder<Postgres> = QueryBuilder::new(
2822            "INSERT INTO peer_or_self_review_questions (
2823        id,
2824        peer_or_self_review_config_id,
2825        order_number,
2826        question,
2827        question_type,
2828        answer_required,
2829        weight,
2830        deleted_at
2831      ) ",
2832        );
2833
2834        let peer_or_self_review_questions = peer_or_self_review_questions
2835            .iter()
2836            .take(1000)
2837            .map(|prq| {
2838                remapped_peer_or_self_review_config_ids
2839                    .get(&prq.peer_or_self_review_config_id)
2840                    .map(|r| (prq, r.id))
2841                    .ok_or_else(|| {
2842                        model_err!(
2843                            Generic,
2844                            "No peer review found for peer review questions".to_string()
2845                        )
2846                    })
2847            })
2848            .collect::<Result<Vec<_>, _>>()?;
2849
2850        sql.push_values(
2851            peer_or_self_review_questions,
2852            |mut x, (prq, peer_or_self_review_config_id)| {
2853                let peer_review_question_exists =
2854                    existing_peer_or_self_review_questions.contains(&prq.id);
2855                let safe_for_db_peer_or_self_review_question_id =
2856                    if retain_ids || peer_review_question_exists {
2857                        prq.id
2858                    } else {
2859                        Uuid::new_v4()
2860                    };
2861                new_peer_or_self_review_question_id_to_old_id
2862                    .insert(safe_for_db_peer_or_self_review_question_id, prq.id);
2863
2864                x.push_bind(safe_for_db_peer_or_self_review_question_id)
2865                    .push_bind(peer_or_self_review_config_id)
2866                    .push_bind(prq.order_number)
2867                    .push_bind(prq.question.as_str())
2868                    .push_bind(prq.question_type)
2869                    .push_bind(prq.answer_required)
2870                    .push_bind(prq.weight)
2871                    .push("NULL");
2872            },
2873        );
2874
2875        sql.push(
2876            " ON CONFLICT (id) DO
2877UPDATE
2878SET peer_or_self_review_config_id = excluded.peer_or_self_review_config_id,
2879    order_number = excluded.order_number,
2880    question = excluded.question,
2881    question_type = excluded.question_type,
2882    answer_required = excluded.answer_required,
2883    weight = excluded.weight,
2884    deleted_at = NULL
2885RETURNING id;
2886",
2887        );
2888
2889        let ids = sql
2890            .build()
2891            .fetch_all(&mut *conn)
2892            .await?
2893            .iter()
2894            .map(|x| x.get(0))
2895            .collect::<Vec<_>>();
2896
2897        let prqs = sqlx::query_as!(
2898            CmsPeerOrSelfReviewQuestion,
2899            r#"
2900SELECT id AS "id!",
2901  answer_required AS "answer_required!",
2902  order_number AS "order_number!",
2903  peer_or_self_review_config_id AS "peer_or_self_review_config_id!",
2904  question AS "question!",
2905  question_type AS "question_type!",
2906  weight AS "weight!"
2907FROM peer_or_self_review_questions
2908WHERE id IN (
2909    SELECT UNNEST($1::uuid [])
2910  )
2911  AND deleted_at is null;
2912        "#,
2913            &ids
2914        )
2915        .fetch_all(&mut *conn)
2916        .await?;
2917
2918        let mut remapped_peer_or_self_review_questions = HashMap::new();
2919
2920        for prq in prqs {
2921            let old_id = new_peer_or_self_review_question_id_to_old_id
2922                .get(&prq.id)
2923                .ok_or_else(|| {
2924                    model_err!(Generic, "Inserted peer reviews not found".to_string())
2925                })?;
2926            remapped_peer_or_self_review_questions.insert(*old_id, prq);
2927        }
2928
2929        Ok(remapped_peer_or_self_review_questions)
2930    }
2931}
2932
2933/// Only used when testing.
2934pub async fn update_page_content(
2935    conn: &mut PgConnection,
2936    page_id: Uuid,
2937    content: &serde_json::Value,
2938) -> ModelResult<()> {
2939    sqlx::query!(
2940        "
2941UPDATE pages
2942SET content = $1
2943WHERE id = $2;
2944",
2945        content,
2946        page_id
2947    )
2948    .execute(conn)
2949    .await?;
2950    Ok(())
2951}
2952
2953#[derive(Debug)]
2954struct ExerciseTaskIdAndSpec {
2955    pub id: Uuid,
2956    pub private_spec: Option<serde_json::Value>,
2957    pub public_spec: Option<serde_json::Value>,
2958    pub model_solution_spec: Option<serde_json::Value>,
2959}
2960
2961/// A derived spec, with the stored files the exercise service declared it references.
2962#[derive(Debug)]
2963struct DerivedSpec {
2964    spec: Option<serde_json::Value>,
2965    /// `None` when this save declared nothing about the spec: either it was not re-derived, so the
2966    /// stored declarations still stand, or the service does not declare its spec files at all.
2967    /// Distinct from `Some(vec![])`, which says the spec references no files and releases whatever
2968    /// it referenced before.
2969    declared_files: Option<Vec<Uuid>>,
2970}
2971
2972/// The body a service that declares its spec files returns from the public-spec and
2973/// model-solution endpoints, in place of the bare spec.
2974///
2975/// Both keys are required on purpose: serde reads a missing field of either an `Option` or a
2976/// `#[serde(default)]` type without complaint, so a lenient envelope would swallow a bare spec
2977/// object as "no spec, no files" and store `NULL` over a working exercise.
2978#[derive(Deserialize)]
2979struct DerivedSpecResponse {
2980    /// `null` for a kind the service has no spec for, such as a model solution it cannot show.
2981    spec: serde_json::Value,
2982    files: Vec<Uuid>,
2983}
2984
2985async fn fetch_derived_spec(
2986    existing_exercise_task: Option<&ExerciseTaskIdAndSpec>,
2987    task_update: &NormalizedCmsExerciseTask,
2988    urls_by_exercise_type: &HashMap<&String, Url>,
2989    spec_fetcher: impl SpecFetcher,
2990    previous_spec: Option<serde_json::Value>,
2991    cms_block_id: Uuid,
2992    declares_spec_files: bool,
2993) -> Result<DerivedSpec, ModelError> {
2994    match existing_exercise_task {
2995        Some(exercise_task) if exercise_task.private_spec == task_update.private_spec => {
2996            // Skip generating public spec for an existing exercise again if private spec is still the same.
2997            Ok(DerivedSpec {
2998                spec: previous_spec,
2999                declared_files: None,
3000            })
3001        }
3002        _ => {
3003            let url = urls_by_exercise_type
3004                .get(&task_update.exercise_type)
3005                .ok_or_else(|| {
3006                    model_err!(
3007                        PreconditionFailedWithCMSAnchorBlockId {
3008                            id: cms_block_id,
3009                            description: "Missing exercise type for exercise task.",
3010                        },
3011                        "Missing exercise type for exercise task.".to_string()
3012                    )
3013                })?
3014                .clone();
3015            let res = spec_fetcher(
3016                url,
3017                &task_update.exercise_type,
3018                task_update.private_spec.as_ref(),
3019            )
3020            .await?;
3021            if !declares_spec_files {
3022                return Ok(DerivedSpec {
3023                    spec: Some(res),
3024                    declared_files: None,
3025                });
3026            }
3027            // A declaring service answers with an envelope, so the same body cannot be read both
3028            // ways: failing loudly beats storing an envelope as if it were the spec.
3029            let response: DerivedSpecResponse = serde_json::from_value(res).map_err(|err| {
3030                model_err!(
3031                    PreconditionFailedWithCMSAnchorBlockId {
3032                        id: cms_block_id,
3033                        description: "Exercise service declares spec files but did not answer with a spec and its files.",
3034                    },
3035                    format!(
3036                        "Exercise service {} declares spec files, so its derived spec must be a spec and its files: {err}",
3037                        task_update.exercise_type
3038                    )
3039                )
3040            })?;
3041            Ok(DerivedSpec {
3042                spec: match response.spec {
3043                    serde_json::Value::Null => None,
3044                    spec => Some(spec),
3045                },
3046                declared_files: Some(response.files),
3047            })
3048        }
3049    }
3050}
3051
3052pub async fn insert_new_content_page(
3053    conn: &mut PgConnection,
3054    new_page: NewPage,
3055    user: Uuid,
3056    spec_fetcher: impl SpecFetcher,
3057    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
3058) -> ModelResult<Page> {
3059    let mut tx = conn.begin().await?;
3060
3061    let course_material_content = vec![GutenbergBlock::hero_section(new_page.title.trim(), "")];
3062
3063    let content_page = NewPage {
3064        chapter_id: new_page.chapter_id,
3065        content: course_material_content,
3066        course_id: new_page.course_id,
3067        exam_id: None,
3068        front_page_of_chapter_id: None,
3069        title: new_page.title,
3070        url_path: new_page.url_path,
3071        exercises: vec![],
3072        exercise_slides: vec![],
3073        exercise_tasks: vec![],
3074        content_search_language: None,
3075        hidden: new_page.hidden,
3076    };
3077    let page = crate::pages::insert_page(
3078        &mut tx,
3079        content_page,
3080        user,
3081        spec_fetcher,
3082        fetch_service_info,
3083    )
3084    .await?;
3085
3086    tx.commit().await?;
3087    Ok(page)
3088}
3089
3090pub async fn insert_page(
3091    conn: &mut PgConnection,
3092    new_page: NewPage,
3093    author: Uuid,
3094    spec_fetcher: impl SpecFetcher,
3095    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
3096) -> ModelResult<Page> {
3097    let next_order_number = match (new_page.chapter_id, new_page.course_id) {
3098        (Some(id), _) => get_next_page_order_number_in_chapter(conn, id).await?,
3099        (None, Some(course_id)) => {
3100            get_next_order_number_for_courses_top_level_pages(conn, course_id).await?
3101        }
3102        (None, None) => 1,
3103    };
3104
3105    let course: OptionFuture<_> = new_page
3106        .course_id
3107        .map(|id| crate::courses::get_course(conn, id))
3108        .into();
3109    let course = course.await.transpose()?;
3110
3111    let content = replace_duplicate_client_ids(new_page.content.clone());
3112
3113    let mut tx = conn.begin().await?;
3114
3115    let page = insert_page_row(
3116        &mut tx,
3117        PageRow {
3118            id: PKeyPolicy::Generate,
3119            course: course.as_ref(),
3120            exam_id: new_page.exam_id,
3121            chapter_id: new_page.chapter_id,
3122            url_path: &new_page.url_path,
3123            title: &new_page.title,
3124            order_number: next_order_number,
3125            hidden: new_page.hidden,
3126            content_search_language: new_page.content_search_language.as_deref(),
3127            page_language_group_id: None,
3128        },
3129        &serde_json::to_value(content)?,
3130    )
3131    .await?;
3132
3133    let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(page.content)?;
3134
3135    let cms_page = update_page(
3136        &mut tx,
3137        PageUpdateArgs {
3138            page_id: page.id,
3139            author: HistoryAuthor::user(author),
3140            cms_page_update: CmsPageUpdate {
3141                content: parsed_content,
3142                exercises: new_page.exercises,
3143                exercise_slides: new_page.exercise_slides,
3144                exercise_tasks: new_page.exercise_tasks,
3145                url_path: page.url_path,
3146                title: page.title,
3147                chapter_id: page.chapter_id,
3148                hidden: new_page.hidden,
3149            },
3150            retain_ids: false,
3151            history_change_reason: HistoryChangeReason::PageSaved,
3152            is_exam_page: new_page.exam_id.is_some(),
3153        },
3154        spec_fetcher,
3155        fetch_service_info,
3156    )
3157    .await?;
3158
3159    if let Some(front_page_of_chapter_id) = new_page.front_page_of_chapter_id {
3160        let _res = sqlx::query_as!(
3161            DatabaseChapter,
3162            r#"
3163UPDATE chapters
3164SET front_page_id = $1
3165WHERE id = $2
3166RETURNING *;
3167        "#,
3168            page.id,
3169            front_page_of_chapter_id
3170        )
3171        // this should fail if no rows returned
3172        .fetch_one(&mut *tx)
3173        .await?;
3174    }
3175
3176    tx.commit().await?;
3177    Ok(Page {
3178        content: cms_page.page.content,
3179        course_id: page.course_id,
3180        exam_id: page.exam_id,
3181        created_at: page.created_at,
3182        updated_at: page.updated_at,
3183        deleted_at: page.deleted_at,
3184        id: page.id,
3185        title: cms_page.page.title,
3186        url_path: cms_page.page.url_path,
3187        order_number: page.order_number,
3188        chapter_id: page.chapter_id,
3189        copied_from: page.copied_from,
3190        hidden: page.hidden,
3191        page_language_group_id: page.page_language_group_id,
3192    })
3193}
3194
3195pub async fn create_for_course_id(
3196    conn: &mut PgConnection,
3197    course_id: Uuid,
3198    new_page: NewPage,
3199    author: Uuid,
3200    spec_fetcher: impl SpecFetcher,
3201    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
3202) -> ModelResult<Page> {
3203    if new_page.course_id != Some(course_id) || new_page.exam_id.is_some() {
3204        return Err(model_err!(
3205            PreconditionFailed,
3206            "Page must be created for the expected course".to_string()
3207        ));
3208    }
3209
3210    let mut tx = conn.begin().await?;
3211
3212    if let Some(chapter_id) = new_page.chapter_id {
3213        let chapter = get_chapter(&mut tx, chapter_id).await?;
3214        if chapter.course_id != course_id {
3215            return Err(model_err!(
3216                PreconditionFailed,
3217                "Chapter must belong to the expected course".to_string()
3218            ));
3219        }
3220    }
3221
3222    if let Some(front_page_of_chapter_id) = new_page.front_page_of_chapter_id {
3223        let chapter = get_chapter(&mut tx, front_page_of_chapter_id).await?;
3224        if chapter.course_id != course_id {
3225            return Err(model_err!(
3226                PreconditionFailed,
3227                "Chapter must belong to the expected course".to_string()
3228            ));
3229        }
3230        if new_page.chapter_id.is_none() || new_page.chapter_id != new_page.front_page_of_chapter_id
3231        {
3232            return Err(model_err!(
3233                PreconditionFailed,
3234                "Page chapter_id must match front_page_of_chapter_id".to_string()
3235            ));
3236        }
3237    }
3238
3239    let mut new_page = new_page;
3240    if new_page.content.is_empty() {
3241        new_page.content = vec![GutenbergBlock::hero_section(new_page.title.trim(), "")];
3242    }
3243
3244    let page = insert_page(&mut tx, new_page, author, spec_fetcher, fetch_service_info).await?;
3245    tx.commit().await?;
3246    Ok(page)
3247}
3248
3249/// A feedback or edit proposal on a page and the blocks of the page it is about, in the order the
3250/// record lists them.
3251#[derive(Debug, Clone, PartialEq, Eq)]
3252pub struct PageBlockReferences {
3253    pub id: Uuid,
3254    pub page_id: Uuid,
3255    pub block_ids: Vec<Uuid>,
3256}
3257
3258/// Fields of a page row that exist before its content is written.
3259pub struct PageRow<'a> {
3260    pub id: PKeyPolicy<Uuid>,
3261    /// The page's course; `None` for an exam page.
3262    pub course: Option<&'a Course>,
3263    pub exam_id: Option<Uuid>,
3264    pub chapter_id: Option<Uuid>,
3265    pub url_path: &'a str,
3266    pub title: &'a str,
3267    pub order_number: i32,
3268    pub hidden: bool,
3269    /// Used when the course has none.
3270    pub content_search_language: Option<&'a str>,
3271    /// The page language group of a course page's versions in other languages to join; `None`
3272    /// puts the page in a new group.
3273    pub page_language_group_id: Option<Uuid>,
3274}
3275
3276/// Creates the snapshot's page empty under its id, or brings back the deleted page with that id,
3277/// so that [update_page] can write its content and exercises next. Writes no page history: the
3278/// content save that follows does. Errors with `PreconditionFailed` if the page belongs to another
3279/// course.
3280async fn insert_or_undelete_course_page_row(
3281    conn: &mut PgConnection,
3282    course: &Course,
3283    page: &crate::library::entity_snapshots::PageSnapshot,
3284) -> ModelResult<()> {
3285    let existing_course_id =
3286        sqlx::query_scalar!("SELECT course_id FROM pages WHERE id = $1", page.id)
3287            .fetch_optional(&mut *conn)
3288            .await?;
3289    let Some(existing_course_id) = existing_course_id else {
3290        insert_page_row(
3291            conn,
3292            PageRow {
3293                id: PKeyPolicy::Fixed(page.id),
3294                course: Some(course),
3295                exam_id: None,
3296                chapter_id: page.chapter_id,
3297                url_path: &page.url_path,
3298                title: &page.title,
3299                order_number: page.order_number,
3300                hidden: page.hidden,
3301                content_search_language: None,
3302                page_language_group_id: page.joins_page_language_group_id,
3303            },
3304            &serde_json::json!([]),
3305        )
3306        .await?;
3307        return Ok(());
3308    };
3309    if existing_course_id != Some(course.id) {
3310        return Err(model_err!(
3311            PreconditionFailed,
3312            "A page with this id belongs to another course".to_string()
3313        ));
3314    }
3315    sqlx::query!(
3316        r#"
3317UPDATE pages
3318SET deleted_at = NULL,
3319  chapter_id = $2,
3320  url_path = $3,
3321  title = $4,
3322  order_number = $5,
3323  hidden = $6
3324WHERE id = $1
3325        "#,
3326        page.id,
3327        page.chapter_id,
3328        normalize_url_path_for_storage(&page.url_path),
3329        page.title.trim(),
3330        page.order_number,
3331        page.hidden,
3332    )
3333    .execute(&mut *conn)
3334    .await?;
3335    Ok(())
3336}
3337
3338/// Inserts a page with the given content, a course page in a new page language group of its
3339/// course. Writes no page history.
3340pub async fn insert_page_row(
3341    conn: &mut PgConnection,
3342    row: PageRow<'_>,
3343    content: &serde_json::Value,
3344) -> ModelResult<Page> {
3345    let page_language_group_id = match row.course {
3346        Some(_) if row.page_language_group_id.is_some() => row.page_language_group_id,
3347        Some(course) => Some(
3348            crate::page_language_groups::insert(
3349                &mut *conn,
3350                PKeyPolicy::Generate,
3351                course.course_language_group_id,
3352            )
3353            .await?,
3354        ),
3355        None => None,
3356    };
3357    let content_search_language = row
3358        .course
3359        .and_then(|course| course.content_search_language.as_deref())
3360        .or(row.content_search_language)
3361        .unwrap_or("simple");
3362    let page = sqlx::query_as!(
3363        Page,
3364        r#"
3365INSERT INTO pages(
3366    id,
3367    course_id,
3368    exam_id,
3369    content,
3370    url_path,
3371    title,
3372    order_number,
3373    chapter_id,
3374    content_search_language,
3375    page_language_group_id,
3376    hidden
3377  )
3378VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
3379RETURNING id,
3380  created_at,
3381  updated_at,
3382  course_id,
3383  exam_id,
3384  chapter_id,
3385  url_path,
3386  title,
3387  deleted_at,
3388  content,
3389  order_number,
3390  copied_from,
3391  hidden,
3392  page_language_group_id
3393        "#,
3394        row.id.into_uuid(),
3395        row.course.map(|course| course.id),
3396        row.exam_id,
3397        content,
3398        normalize_url_path_for_storage(row.url_path),
3399        row.title.trim(),
3400        row.order_number,
3401        row.chapter_id,
3402        content_search_language as _,
3403        page_language_group_id,
3404        row.hidden,
3405    )
3406    .fetch_one(&mut *conn)
3407    .await?;
3408    Ok(page)
3409}
3410
3411/// Writes every field of the snapshot to the page through the CMS page save, creating the page
3412/// with the snapshot's id first when `is_create`. The save records page history as `author`.
3413/// Returns the redirect from the page's old path when its path changed.
3414pub async fn upsert_from_snapshot(
3415    conn: &mut PgConnection,
3416    course: &Course,
3417    page: &crate::library::entity_snapshots::PageSnapshot,
3418    is_create: bool,
3419    author: HistoryAuthor,
3420    spec_fetcher: impl SpecFetcher,
3421    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
3422) -> ModelResult<Option<CreatedRedirect>> {
3423    let mut redirect = None;
3424    if is_create {
3425        insert_or_undelete_course_page_row(conn, course, page).await?;
3426    } else {
3427        sqlx::query!(
3428            "UPDATE pages SET order_number = $2 WHERE id = $1",
3429            page.id,
3430            page.order_number
3431        )
3432        .execute(&mut *conn)
3433        .await?;
3434        redirect = set_paths(conn, &[(page.id, &page.url_path)]).await?.pop();
3435    }
3436    update_page(
3437        conn,
3438        PageUpdateArgs {
3439            page_id: page.id,
3440            author,
3441            cms_page_update: page.to_cms_page_update(),
3442            retain_ids: true,
3443            history_change_reason: HistoryChangeReason::PageSaved,
3444            is_exam_page: false,
3445        },
3446        spec_fetcher,
3447        fetch_service_info,
3448    )
3449    .await?;
3450    Ok(redirect)
3451}
3452
3453/// Moves pages to new paths, normalized as the page save stores them, and redirects each moved
3454/// course page's old path to it. Returns those redirects; pages already at their path, deleted
3455/// pages and exam pages get none.
3456pub async fn set_paths(
3457    conn: &mut PgConnection,
3458    new_paths: &[(Uuid, &str)],
3459) -> ModelResult<Vec<CreatedRedirect>> {
3460    let (ids, url_paths): (Vec<Uuid>, Vec<String>) = new_paths
3461        .iter()
3462        .map(|(id, url_path)| (*id, normalize_url_path_for_storage(url_path)))
3463        .unzip();
3464    write_paths(conn, &ids, &url_paths).await
3465}
3466
3467/// [`set_paths`] with the paths written as given; `ids` and `url_paths` are parallel.
3468async fn write_paths(
3469    conn: &mut PgConnection,
3470    ids: &[Uuid],
3471    url_paths: &[String],
3472) -> ModelResult<Vec<CreatedRedirect>> {
3473    let redirects = sqlx::query_as!(
3474        CreatedRedirect,
3475        r#"
3476WITH moved AS (
3477  UPDATE pages
3478  SET url_path = new.url_path
3479  FROM UNNEST($1::uuid [], $2::text []) AS new(id, url_path)
3480    JOIN pages old ON old.id = new.id
3481  WHERE pages.id = new.id
3482    AND pages.deleted_at IS NULL
3483    AND old.url_path <> new.url_path
3484  RETURNING pages.id,
3485    pages.course_id,
3486    old.url_path AS old_url_path,
3487    pages.url_path AS new_url_path
3488),
3489redirected AS (
3490  INSERT INTO url_redirections (destination_page_id, old_url_path, course_id)
3491  SELECT id,
3492    old_url_path,
3493    course_id
3494  FROM moved
3495  WHERE course_id IS NOT NULL ON CONFLICT (old_url_path, course_id, deleted_at) DO
3496  UPDATE
3497  SET destination_page_id = EXCLUDED.destination_page_id
3498)
3499SELECT id AS "page_id!",
3500  old_url_path AS "old_url_path!",
3501  new_url_path AS "new_url_path!"
3502FROM moved
3503WHERE course_id IS NOT NULL
3504        "#,
3505        ids,
3506        url_paths,
3507    )
3508    .fetch_all(conn)
3509    .await?;
3510    Ok(redirects)
3511}
3512
3513/// Whether the page exists and is not deleted.
3514pub async fn exists(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<bool> {
3515    let exists = sqlx::query_scalar!(
3516        r#"SELECT EXISTS (SELECT 1 FROM pages WHERE id = $1 AND deleted_at IS NULL) AS "exists!""#,
3517        page_id,
3518    )
3519    .fetch_one(conn)
3520    .await?;
3521    Ok(exists)
3522}
3523
3524pub async fn delete_page_and_exercises(
3525    conn: &mut PgConnection,
3526    page_id: Uuid,
3527    author: HistoryAuthor,
3528) -> ModelResult<Page> {
3529    let mut tx = conn.begin().await?;
3530
3531    let is_chapter_front_page = sqlx::query_scalar!(
3532        r#"
3533SELECT EXISTS(
3534  SELECT 1 FROM chapters WHERE front_page_id = $1 AND deleted_at IS NULL
3535) AS "exists!"
3536        "#,
3537        page_id
3538    )
3539    .fetch_one(&mut *tx)
3540    .await?;
3541    if is_chapter_front_page {
3542        return Err(model_err!(
3543            InvalidRequest,
3544            "Cannot delete a chapter's front page".to_string()
3545        ));
3546    }
3547
3548    let is_course_front_page = sqlx::query_scalar!(
3549        r#"
3550SELECT EXISTS(
3551  SELECT 1 FROM pages
3552  WHERE id = $1
3553    AND deleted_at IS NULL
3554    AND chapter_id IS NULL
3555    AND course_id IS NOT NULL
3556    AND trim(url_path) = '/'
3557) AS "exists!"
3558        "#,
3559        page_id
3560    )
3561    .fetch_one(&mut *tx)
3562    .await?;
3563    if is_course_front_page {
3564        return Err(model_err!(
3565            InvalidRequest,
3566            "Cannot delete a course's front page".to_string()
3567        ));
3568    }
3569
3570    let page = sqlx::query_as!(
3571        Page,
3572        r#"
3573UPDATE pages
3574SET deleted_at = now()
3575WHERE id = $1
3576AND deleted_at IS NULL
3577RETURNING id,
3578  created_at,
3579  updated_at,
3580  course_id,
3581  exam_id,
3582  chapter_id,
3583  url_path,
3584  title,
3585  deleted_at,
3586  content,
3587  order_number,
3588  copied_from,
3589  hidden,
3590  page_language_group_id
3591          "#,
3592        page_id,
3593    )
3594    .fetch_one(&mut *tx)
3595    .await?;
3596
3597    sqlx::query!(
3598        r#"
3599  UPDATE exercises
3600  SET deleted_at = now()
3601  WHERE page_id = $1
3602  AND deleted_at IS NULL
3603          "#,
3604        page_id,
3605    )
3606    .execute(&mut *tx)
3607    .await?;
3608
3609    sqlx::query!(
3610        "
3611UPDATE exercise_slides
3612SET deleted_at = now()
3613WHERE exercise_id IN (
3614    SELECT id
3615    FROM exercises
3616    WHERE page_id = $1
3617  )
3618  AND deleted_at IS NULL;
3619        ",
3620        page.id
3621    )
3622    .execute(&mut *tx)
3623    .await?;
3624
3625    sqlx::query!(
3626        r#"
3627UPDATE exercise_tasks
3628SET deleted_at = now()
3629WHERE exercise_slide_id IN (
3630    SELECT s.id
3631    FROM exercise_slides s
3632      JOIN exercises e ON (s.exercise_id = e.id)
3633    WHERE e.page_id = $1
3634  )
3635  AND deleted_at IS NULL;
3636            "#,
3637        page.id
3638    )
3639    .execute(&mut *tx)
3640    .await?;
3641
3642    let history_content = PageHistoryContent {
3643        content: json!(null),
3644        exercises: Vec::new(),
3645        exercise_slides: Vec::new(),
3646        exercise_tasks: Vec::new(),
3647        peer_or_self_review_configs: Vec::new(),
3648        peer_or_self_review_questions: Vec::new(),
3649    };
3650    crate::page_history::insert(
3651        &mut tx,
3652        PKeyPolicy::Generate,
3653        page.id,
3654        &page.title,
3655        &history_content,
3656        HistoryChangeReason::PageDeleted,
3657        author,
3658        None,
3659    )
3660    .await?;
3661
3662    tx.commit().await?;
3663    Ok(page)
3664}
3665
3666pub async fn get_chapters_pages_with_exercises(
3667    conn: &mut PgConnection,
3668    chapters_id: Uuid,
3669) -> ModelResult<Vec<PageWithExercises>> {
3670    let chapter_pages = sqlx::query_as!(
3671        Page,
3672        r#"
3673SELECT id,
3674  created_at,
3675  updated_at,
3676  course_id,
3677  exam_id,
3678  chapter_id,
3679  url_path,
3680  title,
3681  deleted_at,
3682  content,
3683  order_number,
3684  copied_from,
3685  hidden,
3686  page_language_group_id
3687FROM pages
3688WHERE chapter_id = $1
3689  AND deleted_at IS NULL
3690        "#,
3691        chapters_id
3692    )
3693    .fetch_all(&mut *conn)
3694    .await?;
3695    let page_ids: Vec<Uuid> = chapter_pages.iter().map(|page| page.id).collect();
3696    let pages_exercises = sqlx::query_as!(
3697        Exercise,
3698        r#"
3699SELECT *
3700FROM exercises
3701WHERE page_id IN (
3702    SELECT UNNEST($1::uuid [])
3703  )
3704  AND deleted_at IS NULL
3705        "#,
3706        &page_ids
3707    )
3708    .fetch_all(&mut *conn)
3709    .await?;
3710
3711    let mut page_to_exercises: HashMap<Uuid, Vec<Exercise>> = pages_exercises
3712        .into_iter()
3713        .into_group_map_by(|exercise| exercise.page_id);
3714    let mut chapter_pages_with_exercises: Vec<PageWithExercises> = chapter_pages
3715        .into_iter()
3716        .map(|page| {
3717            let page_id = page.id;
3718            let mut exercises = page_to_exercises.remove(&page_id).unwrap_or_default();
3719
3720            exercises.sort_by_key(|a| a.order_number);
3721            PageWithExercises { page, exercises }
3722        })
3723        .collect();
3724
3725    chapter_pages_with_exercises.sort_by_key(|a| a.page.order_number);
3726
3727    Ok(chapter_pages_with_exercises)
3728}
3729
3730pub async fn get_next_page(
3731    conn: &mut PgConnection,
3732    page_id: Uuid,
3733    visibility: PageVisibility,
3734) -> ModelResult<Option<PageRoutingData>> {
3735    let page_metadata = get_current_page_metadata(conn, page_id).await?;
3736    let next_page = get_next_page_by_order_number(conn, &page_metadata, visibility).await?;
3737
3738    match next_page {
3739        Some(next_page) => Ok(Some(next_page)),
3740        None => {
3741            let first_page =
3742                get_next_page_by_chapter_number(conn, &page_metadata, visibility).await?;
3743            Ok(first_page)
3744        }
3745    }
3746}
3747
3748async fn get_current_page_metadata(
3749    conn: &mut PgConnection,
3750    page_id: Uuid,
3751) -> ModelResult<PageMetadata> {
3752    let page_metadata = sqlx::query_as!(
3753        PageMetadata,
3754        r#"
3755-- Nullability is stated per column rather than inferred: sqlx-cli 0.9.0 derives a reversed
3756-- nullable array for this query, which makes every offline build of it fail.
3757SELECT p.id as "page_id!",
3758  p.order_number as "order_number!",
3759  p.course_id as "course_id?",
3760  p.exam_id as "exam_id?",
3761  c.id as "chapter_id?",
3762  c.chapter_number as "chapter_number?"
3763FROM pages p
3764  LEFT JOIN chapters c ON p.chapter_id = c.id
3765WHERE p.id = $1;
3766"#,
3767        page_id
3768    )
3769    .fetch_one(conn)
3770    .await?;
3771
3772    if page_metadata.chapter_number.is_none() {
3773        return Err(model_err!(
3774            InvalidRequest,
3775            "Page is not related to any chapter".to_string()
3776        ));
3777    }
3778
3779    Ok(page_metadata)
3780}
3781
3782async fn get_next_page_by_order_number(
3783    conn: &mut PgConnection,
3784    current_page_metadata: &PageMetadata,
3785    visibility: PageVisibility,
3786) -> ModelResult<Option<PageRoutingData>> {
3787    let inverse_visibility_filter = visibility.get_inverse_visibility_filter();
3788    let next_page = sqlx::query_as!(
3789        PageRoutingData,
3790        r#"
3791SELECT p.url_path as url_path,
3792  p.title as title,
3793  p.id as page_id,
3794  c.chapter_number as chapter_number,
3795  c.id as chapter_id,
3796  c.opens_at as chapter_opens_at,
3797  c.front_page_id as chapter_front_page_id
3798FROM pages p
3799  LEFT JOIN chapters c ON p.chapter_id = c.id
3800WHERE p.order_number = (
3801    SELECT MIN(pa.order_number)
3802    FROM pages pa
3803    WHERE pa.order_number > $1
3804      AND pa.deleted_at IS NULL
3805      AND pa.hidden IS DISTINCT FROM $4
3806      AND pa.course_id = p.course_id
3807      AND pa.chapter_id IS NOT DISTINCT FROM p.chapter_id
3808  )
3809  AND p.course_id = $2
3810  AND c.chapter_number = $3
3811  AND p.deleted_at IS NULL
3812  AND p.hidden IS DISTINCT FROM $4;
3813        "#,
3814        current_page_metadata.order_number,
3815        current_page_metadata.course_id,
3816        current_page_metadata.chapter_number,
3817        inverse_visibility_filter,
3818    )
3819    .fetch_optional(conn)
3820    .await?;
3821
3822    Ok(next_page)
3823}
3824
3825async fn get_next_page_by_chapter_number(
3826    conn: &mut PgConnection,
3827    current_page_metadata: &PageMetadata,
3828    visibility: PageVisibility,
3829) -> ModelResult<Option<PageRoutingData>> {
3830    let inverse_visibility_filter = visibility.get_inverse_visibility_filter();
3831    let next_page = sqlx::query_as!(
3832        PageRoutingData,
3833        r#"
3834SELECT p.url_path as url_path,
3835  p.title as title,
3836  p.id as page_id,
3837  c.chapter_number as chapter_number,
3838  c.id as chapter_id,
3839  c.opens_at as chapter_opens_at,
3840  c.front_page_id as chapter_front_page_id
3841FROM chapters c
3842  INNER JOIN pages p on c.id = p.chapter_id
3843WHERE c.chapter_number = (
3844    SELECT MIN(ca.chapter_number)
3845    FROM chapters ca
3846    WHERE ca.chapter_number > $1
3847      AND ca.deleted_at IS NULL
3848  )
3849  AND c.course_id = $2
3850  AND p.deleted_at IS NULL
3851  AND p.hidden IS DISTINCT FROM $3
3852ORDER BY p.order_number
3853LIMIT 1;
3854        "#,
3855        current_page_metadata.chapter_number,
3856        current_page_metadata.course_id,
3857        inverse_visibility_filter,
3858    )
3859    .fetch_optional(conn)
3860    .await?;
3861
3862    Ok(next_page)
3863}
3864
3865async fn get_next_page_order_number_in_chapter(
3866    conn: &mut PgConnection,
3867    chapter_id: Uuid,
3868) -> ModelResult<i32> {
3869    let next_order_number = sqlx::query!(
3870        "
3871select max(p.order_number) as order_number
3872from pages p
3873where p.chapter_id = $1
3874  and p.deleted_at is null;
3875",
3876        chapter_id
3877    )
3878    .fetch_one(conn)
3879    .await?;
3880
3881    match next_order_number.order_number {
3882        Some(order_number) => Ok(order_number + 1),
3883        None => Ok(0),
3884    }
3885}
3886
3887pub async fn get_page_navigation_data(
3888    conn: &mut PgConnection,
3889    page_id: Uuid,
3890    visibility: PageVisibility,
3891) -> ModelResult<PageNavigationInformation> {
3892    let previous_page_data = get_previous_page(conn, page_id, visibility).await?;
3893
3894    let next_page_data = get_next_page(conn, page_id, visibility).await?;
3895
3896    let chapter_front_page = get_chapter_front_page_by_page_id(conn, page_id).await?;
3897    // This may be different from the chapter of the previous page and the chapter of the next page so we need to fetch it to be sure.
3898    let chapter_front_page_chapter = OptionFuture::from(
3899        chapter_front_page
3900            .clone()
3901            .and_then(|front_page| front_page.chapter_id)
3902            .map(|chapter_id| chapters::get_chapter(conn, chapter_id)),
3903    )
3904    .await
3905    .transpose()?;
3906
3907    let chapter_front_page = chapter_front_page
3908        .map(|front_page| -> ModelResult<_> {
3909            if let Some(chapter_front_page_chapter) = chapter_front_page_chapter {
3910                Ok(PageRoutingData {
3911                    url_path: front_page.url_path,
3912                    title: front_page.title,
3913                    page_id: front_page.id,
3914                    chapter_number: chapter_front_page_chapter.chapter_number,
3915                    chapter_id: chapter_front_page_chapter.id,
3916                    chapter_opens_at: chapter_front_page_chapter.opens_at,
3917                    chapter_front_page_id: Some(front_page.id),
3918                })
3919            } else {
3920                Err(model_err!(
3921                    InvalidRequest,
3922                    "Chapter front page chapter not found".to_string()
3923                ))
3924            }
3925        })
3926        .transpose()?;
3927    Ok(PageNavigationInformation {
3928        chapter_front_page,
3929        next_page: next_page_data,
3930        previous_page: previous_page_data,
3931    })
3932}
3933
3934pub async fn get_previous_page(
3935    conn: &mut PgConnection,
3936    page_id: Uuid,
3937    visibility: PageVisibility,
3938) -> ModelResult<Option<PageRoutingData>> {
3939    let page_metadata = get_current_page_metadata(conn, page_id).await?;
3940    let previous_page = get_previous_page_by_order_number(conn, &page_metadata, visibility).await?;
3941
3942    match previous_page {
3943        Some(previous_page) => Ok(Some(previous_page)),
3944        None => {
3945            let first_page =
3946                get_previous_page_by_chapter_number(conn, &page_metadata, visibility).await?;
3947            Ok(first_page)
3948        }
3949    }
3950}
3951
3952pub async fn get_chapter_front_page_by_page_id(
3953    conn: &mut PgConnection,
3954    page_id: Uuid,
3955) -> ModelResult<Option<Page>> {
3956    let page_metadata = get_current_page_metadata(conn, page_id).await?;
3957    let chapter = chapters::get_chapter_info_by_page_metadata(conn, &page_metadata).await?;
3958    let page_option_future: OptionFuture<_> = chapter
3959        .chapter_front_page_id
3960        .map(|chapter_front_page_id| get_page(conn, chapter_front_page_id))
3961        .into();
3962    let page = page_option_future.await.transpose()?;
3963    Ok(page)
3964}
3965
3966async fn get_previous_page_by_order_number(
3967    conn: &mut PgConnection,
3968    current_page_metadata: &PageMetadata,
3969    visibility: PageVisibility,
3970) -> ModelResult<Option<PageRoutingData>> {
3971    let inverse_visibility_filter = visibility.get_inverse_visibility_filter();
3972    let previous_page = sqlx::query_as!(
3973        PageRoutingData,
3974        r#"
3975SELECT p.url_path as url_path,
3976  p.title as title,
3977  c.chapter_number as chapter_number,
3978  p.id as page_id,
3979  c.id as chapter_id,
3980  c.opens_at as chapter_opens_at,
3981  c.front_page_id as chapter_front_page_id
3982FROM pages p
3983  LEFT JOIN chapters c ON p.chapter_id = c.id
3984WHERE p.order_number = (
3985    SELECT MAX(pa.order_number)
3986    FROM pages pa
3987    WHERE pa.order_number < $1
3988      AND pa.deleted_at IS NULL
3989      AND pa.hidden IS DISTINCT FROM $4
3990      AND pa.course_id = p.course_id
3991      AND pa.chapter_id IS NOT DISTINCT FROM p.chapter_id
3992  )
3993  AND p.course_id = $2
3994  AND c.chapter_number = $3
3995  AND p.deleted_at IS NULL
3996  AND p.hidden IS DISTINCT FROM $4;
3997        "#,
3998        current_page_metadata.order_number,
3999        current_page_metadata.course_id,
4000        current_page_metadata.chapter_number,
4001        inverse_visibility_filter,
4002    )
4003    .fetch_optional(conn)
4004    .await?;
4005
4006    Ok(previous_page)
4007}
4008
4009async fn get_previous_page_by_chapter_number(
4010    conn: &mut PgConnection,
4011    current_page_metadata: &PageMetadata,
4012    visibility: PageVisibility,
4013) -> ModelResult<Option<PageRoutingData>> {
4014    let inverse_visibility_filter = visibility.get_inverse_visibility_filter();
4015    let previous_page = sqlx::query_as!(
4016        PageRoutingData,
4017        r#"
4018SELECT p.url_path AS url_path,
4019  p.title AS title,
4020  p.id AS page_id,
4021  c.chapter_number AS chapter_number,
4022  c.id AS chapter_id,
4023  c.opens_at AS chapter_opens_at,
4024  c.front_page_id AS chapter_front_page_id
4025FROM chapters c
4026  INNER JOIN pages p ON c.id = p.chapter_id
4027WHERE c.chapter_number = (
4028    SELECT MAX(ca.chapter_number)
4029    FROM chapters ca
4030    WHERE ca.chapter_number < $1
4031      AND ca.deleted_at IS NULL
4032  )
4033  AND c.course_id = $2
4034  AND p.deleted_at IS NULL
4035  AND p.hidden IS DISTINCT FROM $3
4036ORDER BY p.order_number DESC
4037LIMIT 1;
4038        "#,
4039        current_page_metadata.chapter_number,
4040        current_page_metadata.course_id,
4041        inverse_visibility_filter,
4042    )
4043    .fetch_optional(conn)
4044    .await?;
4045
4046    Ok(previous_page)
4047}
4048
4049async fn get_next_order_number_for_courses_top_level_pages(
4050    conn: &mut PgConnection,
4051    course_id: Uuid,
4052) -> ModelResult<i32> {
4053    let next_order_number = sqlx::query!(
4054        "
4055select max(p.order_number) as order_number
4056from pages p
4057where p.course_id = $1
4058  and p.chapter_id is null
4059  and p.deleted_at is null;
4060",
4061        course_id
4062    )
4063    .fetch_one(conn)
4064    .await?;
4065
4066    match next_order_number.order_number {
4067        Some(order_number) => Ok(order_number + 1),
4068        None => Ok(0),
4069    }
4070}
4071
4072pub async fn get_chapter_pages(
4073    conn: &mut PgConnection,
4074    chapter_id: Uuid,
4075) -> ModelResult<Vec<Page>> {
4076    let pages = sqlx::query_as!(
4077        Page,
4078        "
4079SELECT id,
4080  created_at,
4081  updated_at,
4082  course_id,
4083  exam_id,
4084  chapter_id,
4085  url_path,
4086  title,
4087  deleted_at,
4088  content,
4089  order_number,
4090  copied_from,
4091  hidden,
4092  page_language_group_id
4093FROM pages p
4094WHERE p.chapter_id = $1
4095  AND p.deleted_at IS NULL;
4096    ",
4097        chapter_id
4098    )
4099    .fetch_all(conn)
4100    .await?;
4101
4102    Ok(pages)
4103}
4104
4105pub struct PageIdAndUrlPath {
4106    pub id: Uuid,
4107    pub url_path: String,
4108}
4109
4110/// Like [get_chapter_pages], projected down to `id` and `url_path` for callers that only rewrite
4111/// paths and would otherwise pull every page's `content` JSONB for nothing.
4112pub async fn get_chapter_page_paths(
4113    conn: &mut PgConnection,
4114    chapter_id: Uuid,
4115) -> ModelResult<Vec<PageIdAndUrlPath>> {
4116    let pages = sqlx::query_as!(
4117        PageIdAndUrlPath,
4118        "
4119SELECT id,
4120  url_path
4121FROM pages
4122WHERE chapter_id = $1
4123  AND deleted_at IS NULL;
4124    ",
4125        chapter_id
4126    )
4127    .fetch_all(conn)
4128    .await?;
4129
4130    Ok(pages)
4131}
4132
4133pub async fn get_chapters_visible_pages_exclude_main_frontpage(
4134    conn: &mut PgConnection,
4135    chapter_id: Uuid,
4136) -> ModelResult<Vec<Page>> {
4137    let pages = sqlx::query_as!(
4138        Page,
4139        "
4140SELECT id,
4141  created_at,
4142  updated_at,
4143  course_id,
4144  exam_id,
4145  chapter_id,
4146  url_path,
4147  title,
4148  deleted_at,
4149  content,
4150  order_number,
4151  copied_from,
4152  hidden,
4153  page_language_group_id
4154FROM pages p
4155WHERE p.chapter_id = $1
4156  AND p.deleted_at IS NULL
4157  AND p.hidden IS FALSE
4158  AND p.url_path IS NOT NULL
4159  AND p.id NOT IN (
4160    SELECT front_page_id
4161    FROM chapters c
4162    WHERE c.front_page_id = p.id
4163  );
4164    ",
4165        chapter_id
4166    )
4167    .fetch_all(conn)
4168    .await?;
4169    Ok(pages)
4170}
4171
4172/**
4173Returns search results for a phrase i.e. looks for matches where the words come up right after each other
4174*/
4175pub async fn get_page_search_results_for_phrase(
4176    conn: &mut PgConnection,
4177    course_id: Uuid,
4178    page_search_request: &SearchRequest,
4179) -> ModelResult<Vec<PageSearchResult>> {
4180    get_page_search_results(
4181        conn,
4182        course_id,
4183        page_search_request,
4184        PageSearchQueryType::Phrase,
4185    )
4186    .await
4187}
4188
4189/**
4190Returns search results for the given words. The words can appear in the source document in any order.
4191*/
4192pub async fn get_page_search_results_for_words(
4193    conn: &mut PgConnection,
4194    course_id: Uuid,
4195    page_search_request: &SearchRequest,
4196) -> ModelResult<Vec<PageSearchResult>> {
4197    get_page_search_results(
4198        conn,
4199        course_id,
4200        page_search_request,
4201        PageSearchQueryType::Words,
4202    )
4203    .await
4204}
4205
4206#[derive(Debug, sqlx::FromRow)]
4207struct RawPageSearchResult {
4208    id: Uuid,
4209    title_headline: Option<String>,
4210    rank: Option<f32>,
4211    url_path: String,
4212    chapter_name: Option<String>,
4213    content: Value,
4214}
4215
4216#[derive(Debug, sqlx::FromRow)]
4217struct SearchContentHeadlineRow {
4218    content_headline: Option<String>,
4219}
4220
4221#[derive(Clone, Copy)]
4222enum PageSearchQueryType {
4223    Phrase,
4224    Words,
4225}
4226
4227fn extract_searchable_text_from_content_value(content: &Value) -> String {
4228    let mut searchable_parts = Vec::new();
4229    collect_searchable_text_from_content_value(content, None, &mut searchable_parts);
4230    searchable_parts.join(" ")
4231}
4232
4233fn collect_searchable_text_from_content_value(
4234    value: &Value,
4235    current_key: Option<&str>,
4236    searchable_parts: &mut Vec<String>,
4237) {
4238    match value {
4239        Value::Object(map) => {
4240            for (key, value) in map {
4241                collect_searchable_text_from_content_value(value, Some(key), searchable_parts);
4242            }
4243        }
4244        Value::Array(values) => {
4245            for value in values {
4246                collect_searchable_text_from_content_value(value, None, searchable_parts);
4247            }
4248        }
4249        Value::String(value) if matches!(current_key, Some("content" | "title" | "subtitle")) => {
4250            searchable_parts.push(value.clone());
4251        }
4252        _ => {}
4253    }
4254}
4255
4256fn sanitized_searchable_text_for_public_page(content: &Value) -> String {
4257    let Ok(blocks) = serde_json::from_value::<Vec<GutenbergBlock>>(content.clone()) else {
4258        tracing::warn!(
4259            "Failed to deserialize page content for search snippet sanitization. Falling back to raw searchable text."
4260        );
4261        return extract_searchable_text_from_content_value(content);
4262    };
4263
4264    let filtered_blocks = filter_lock_chapter_blocks(blocks, false);
4265    match serde_json::to_value(filtered_blocks) {
4266        Ok(filtered_content) => extract_searchable_text_from_content_value(&filtered_content),
4267        Err(error) => {
4268            tracing::warn!(
4269                "Failed to serialize filtered page content for search snippet sanitization: {}. Falling back to raw searchable text.",
4270                error
4271            );
4272            extract_searchable_text_from_content_value(content)
4273        }
4274    }
4275}
4276
4277async fn get_page_search_results(
4278    conn: &mut PgConnection,
4279    course_id: Uuid,
4280    page_search_request: &SearchRequest,
4281    query_type: PageSearchQueryType,
4282) -> ModelResult<Vec<PageSearchResult>> {
4283    let course = crate::courses::get_course(&mut *conn, course_id).await?;
4284    let content_search_language = course
4285        .content_search_language
4286        .as_deref()
4287        .unwrap_or("simple");
4288
4289    // Last word of the search term needed so that the sql statement can change it to a prefix match.
4290    // Allows the last word to not be fully typed.
4291    let last_word = if let Some(last) = page_search_request
4292        .query
4293        .trim()
4294        .split_ascii_whitespace()
4295        .last()
4296    {
4297        last.to_string()
4298    } else {
4299        return Ok(Vec::new());
4300    };
4301
4302    let query_builder = match query_type {
4303        PageSearchQueryType::Phrase => "phraseto_tsquery",
4304        PageSearchQueryType::Words => "plainto_tsquery",
4305    };
4306    let query_builder_comment = match query_type {
4307        PageSearchQueryType::Phrase =>
4308            "-- Converts the search term to a phrase search with phraseto_tsquery but appends ':*' to the last word so that it
4309    -- becomes a prefix match. This way the search will also contain results when the last word in the search term
4310    -- is only partially typed. Note that if to_tsquery($4) decides to stem the word, the replacement will be skipped.",
4311        PageSearchQueryType::Words =>
4312            "-- Converts the search term to a word search with ands between the words with plainto_tsquery but appends ':*' to the
4313    -- last word so that it becomes a prefix match. This way the search will also contain results when the last word in
4314    -- the search term is only partially typed. Note that if to_tsquery($4) decides to stem the word, the replacement
4315    -- will be skipped.",
4316    };
4317
4318    let search_results_sql = format!(
4319        r#"
4320WITH cte as (
4321    {query_builder_comment}
4322    SELECT ts_rewrite(
4323        {query_builder}($2::regconfig, $3),
4324        to_tsquery($4),
4325        to_tsquery($4 || ':*')
4326    ) as query
4327)
4328SELECT p.id,
4329    ts_rank(
4330        p.content_search,
4331        (
4332            SELECT query
4333            from cte
4334        )
4335    ) as rank,
4336    ts_headline(
4337        $2::regconfig,
4338        p.title,
4339        (
4340            SELECT query
4341            from cte
4342        ),
4343        'MaxFragments=1, MaxWords=20, MinWords=1'
4344    ) as title_headline,
4345    COALESCE(p.url_path, '') as url_path,
4346    c.name as chapter_name,
4347    p.content
4348FROM pages p
4349LEFT JOIN chapters c ON p.chapter_id = c.id
4350WHERE p.course_id = $1
4351    AND p.deleted_at IS NULL
4352    AND p.hidden IS FALSE
4353    AND p.url_path IS NOT NULL
4354    AND p.content_search @@ (
4355        SELECT query
4356        from cte
4357    )
4358ORDER BY rank DESC
4359LIMIT 50;
4360        "#
4361    );
4362
4363    // The formatted fragments are fixed strings selected from PageSearchQueryType; all request data
4364    // remains passed through bind parameters below.
4365    let raw_results = sqlx::query_as::<_, RawPageSearchResult>(AssertSqlSafe(search_results_sql))
4366        .bind(course_id)
4367        .bind(content_search_language)
4368        .bind(&page_search_request.query)
4369        .bind(&last_word)
4370        .fetch_all(&mut *conn)
4371        .await?;
4372
4373    let sanitized_search_texts = raw_results
4374        .iter()
4375        .map(|result| sanitized_searchable_text_for_public_page(&result.content))
4376        .collect::<Vec<_>>();
4377    let content_headlines = build_public_search_content_headlines(
4378        conn,
4379        content_search_language,
4380        &page_search_request.query,
4381        &last_word,
4382        query_type,
4383        &sanitized_search_texts,
4384    )
4385    .await?;
4386
4387    let search_results = raw_results
4388        .into_iter()
4389        .zip(content_headlines)
4390        .map(|(result, content_headline)| PageSearchResult {
4391            id: result.id,
4392            title_headline: result.title_headline,
4393            rank: result.rank,
4394            content_headline,
4395            url_path: result.url_path,
4396            chapter_name: result.chapter_name,
4397        })
4398        .collect::<Vec<_>>();
4399
4400    Ok(add_course_url_prefix_to_search_results(
4401        search_results,
4402        &course,
4403    ))
4404}
4405
4406async fn build_public_search_content_headlines(
4407    conn: &mut PgConnection,
4408    content_search_language: &str,
4409    query: &str,
4410    last_word: &str,
4411    query_type: PageSearchQueryType,
4412    sanitized_search_texts: &[String],
4413) -> ModelResult<Vec<Option<String>>> {
4414    if sanitized_search_texts.is_empty() {
4415        return Ok(Vec::new());
4416    }
4417
4418    let query_builder = match query_type {
4419        PageSearchQueryType::Phrase => "phraseto_tsquery",
4420        PageSearchQueryType::Words => "plainto_tsquery",
4421    };
4422    let query_builder_comment = match query_type {
4423        PageSearchQueryType::Phrase =>
4424            "-- Converts the search term to a phrase search with phraseto_tsquery but appends ':*' to the last word so that it
4425    -- becomes a prefix match. This way the search will also contain results when the last word in the search term
4426    -- is only partially typed. Note that if to_tsquery($3) decides to stem the word, the replacement will be skipped.",
4427        PageSearchQueryType::Words =>
4428            "-- Converts the search term to a word search with ands between the words with plainto_tsquery but appends ':*' to the
4429    -- last word so that it becomes a prefix match. This way the search will also contain results when the last word in
4430    -- the search term is only partially typed. Note that if to_tsquery($3) decides to stem the word, the replacement
4431    -- will be skipped.",
4432    };
4433
4434    let content_headline_sql = format!(
4435        r#"
4436WITH cte as (
4437    {query_builder_comment}
4438    SELECT ts_rewrite(
4439        {query_builder}($1::regconfig, $2),
4440        to_tsquery($3),
4441        to_tsquery($3 || ':*')
4442    ) as query
4443)
4444SELECT ts_headline(
4445    $1::regconfig,
4446    input.content,
4447    (
4448        SELECT query
4449        from cte
4450    ),
4451    'MaxFragments=1, MaxWords=30, MinWords=10'
4452) as content_headline
4453FROM unnest($4::text[]) WITH ORDINALITY AS input(content, ord)
4454ORDER BY ord;
4455        "#
4456    );
4457
4458    Ok(
4459        // The formatted fragments are fixed strings selected from PageSearchQueryType; all request data
4460        // remains passed through bind parameters below.
4461        sqlx::query_as::<_, SearchContentHeadlineRow>(AssertSqlSafe(content_headline_sql))
4462            .bind(content_search_language)
4463            .bind(query)
4464            .bind(last_word)
4465            .bind(sanitized_search_texts.to_vec())
4466            .fetch_all(&mut *conn)
4467            .await?
4468            .into_iter()
4469            .map(|row| row.content_headline)
4470            .collect(),
4471    )
4472}
4473
4474fn add_course_url_prefix_to_search_results(
4475    search_results: Vec<PageSearchResult>,
4476    course: &Course,
4477) -> Vec<PageSearchResult> {
4478    search_results
4479        .into_iter()
4480        .map(|mut sr| {
4481            let optional_slash = if sr.url_path.starts_with('/') {
4482                ""
4483            } else {
4484                "/"
4485            };
4486            sr.url_path = format!("/{}{}{}", course.slug, optional_slash, sr.url_path);
4487            sr
4488        })
4489        .collect()
4490}
4491
4492/// Restore page contents and exercises to a previous revision
4493pub async fn restore(
4494    conn: &mut PgConnection,
4495    page_id: Uuid,
4496    history_id: Uuid,
4497    author: Uuid,
4498    spec_fetcher: impl SpecFetcher,
4499    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
4500) -> ModelResult<Uuid> {
4501    // fetch old content
4502    let page = get_page(conn, page_id).await?;
4503    let history_data = page_history::get_history_data(conn, history_id).await?;
4504
4505    let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(history_data.content.content)?;
4506
4507    update_page(
4508        conn,
4509        PageUpdateArgs {
4510            page_id: page.id,
4511            author: HistoryAuthor::user(author),
4512            cms_page_update: CmsPageUpdate {
4513                content: parsed_content,
4514                exercises: history_data.content.exercises,
4515                exercise_slides: history_data.content.exercise_slides,
4516                exercise_tasks: history_data.content.exercise_tasks,
4517                url_path: page.url_path,
4518                title: history_data.title,
4519                chapter_id: page.chapter_id,
4520                hidden: page.hidden,
4521            },
4522            retain_ids: true,
4523            history_change_reason: HistoryChangeReason::HistoryRestored,
4524            is_exam_page: history_data.exam_id.is_some(),
4525        },
4526        spec_fetcher,
4527        fetch_service_info,
4528    )
4529    .await?;
4530
4531    Ok(history_id)
4532}
4533
4534#[allow(clippy::too_many_arguments)]
4535pub async fn restore_from_history_for_page_id(
4536    conn: &mut PgConnection,
4537    page_id: Uuid,
4538    history_id: Uuid,
4539    authorized_source_page_id: Option<Uuid>,
4540    expected_course_id: Option<Uuid>,
4541    expected_exam_id: Option<Uuid>,
4542    author: Uuid,
4543    spec_fetcher: impl SpecFetcher,
4544    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
4545) -> ModelResult<Uuid> {
4546    let mut tx = conn.begin().await?;
4547    let validated_source_page_id = if let Some(source_page_id) = authorized_source_page_id {
4548        let source_page = sqlx::query!(
4549            r#"
4550SELECT p.id
4551FROM pages p
4552WHERE p.id = $1
4553  AND p.deleted_at IS NULL
4554  AND (p.course_id = $2 OR p.exam_id = $3)
4555FOR UPDATE
4556            "#,
4557            source_page_id,
4558            expected_course_id,
4559            expected_exam_id
4560        )
4561        .fetch_optional(&mut *tx)
4562        .await?;
4563        source_page.map(|row| row.id)
4564    } else {
4565        None
4566    };
4567
4568    sqlx::query!(
4569        r#"
4570SELECT p.id
4571FROM pages p
4572WHERE p.id = $1
4573  AND p.deleted_at IS NULL
4574  AND (p.course_id = $2 OR p.exam_id = $3)
4575FOR UPDATE
4576        "#,
4577        page_id,
4578        expected_course_id,
4579        expected_exam_id
4580    )
4581    .fetch_one(&mut *tx)
4582    .await?;
4583
4584    sqlx::query!(
4585        r#"
4586SELECT ph.id
4587FROM page_history ph
4588WHERE ph.id = $1
4589  AND ph.deleted_at IS NULL
4590  AND (
4591    ph.page_id = $2
4592    OR ph.page_id = $3
4593  )
4594FOR UPDATE
4595        "#,
4596        history_id,
4597        page_id,
4598        validated_source_page_id
4599    )
4600    .fetch_one(&mut *tx)
4601    .await?;
4602
4603    let restored = restore(
4604        &mut tx,
4605        page_id,
4606        history_id,
4607        author,
4608        spec_fetcher,
4609        fetch_service_info,
4610    )
4611    .await?;
4612    tx.commit().await?;
4613    Ok(restored)
4614}
4615
4616pub async fn get_organization_id(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<Uuid> {
4617    let res = sqlx::query!(
4618        "
4619SELECT organizations.id
4620FROM pages
4621  LEFT OUTER JOIN courses ON courses.id = pages.course_id
4622  LEFT OUTER JOIN exams ON exams.id = pages.exam_id
4623  JOIN organizations ON organizations.id = courses.organization_id
4624  OR organizations.id = exams.organization_id
4625WHERE pages.id = $1
4626",
4627        page_id,
4628    )
4629    .fetch_one(&mut *conn)
4630    .await?;
4631    Ok(res.id)
4632}
4633
4634pub async fn get_page_chapter_and_course_information(
4635    conn: &mut PgConnection,
4636    page_id: Uuid,
4637) -> ModelResult<PageChapterAndCourseInformation> {
4638    let res = sqlx::query_as!(
4639        PageChapterAndCourseInformation,
4640        r#"
4641SELECT chapters.name as "chapter_name?",
4642  chapters.chapter_number as "chapter_number?",
4643  courses.name as "course_name?",
4644  courses.slug as "course_slug?",
4645  chapters.front_page_id as "chapter_front_page_id?",
4646  p2.url_path as "chapter_front_page_url_path?",
4647  organizations.slug as "organization_slug?"
4648FROM pages
4649  LEFT JOIN chapters on pages.chapter_id = chapters.id
4650  LEFT JOIN courses on pages.course_id = courses.id
4651  LEFT JOIN pages p2 ON chapters.front_page_id = p2.id
4652  LEFT JOIN organizations on courses.organization_id = organizations.id
4653WHERE pages.id = $1
4654"#,
4655        page_id,
4656    )
4657    .fetch_one(&mut *conn)
4658    .await?;
4659    Ok(res)
4660}
4661
4662pub async fn get_page_by_course_id_and_language_group(
4663    conn: &mut PgConnection,
4664    course_id: Uuid,
4665    page_language_group_id: Uuid,
4666) -> ModelResult<Page> {
4667    let page = sqlx::query_as!(
4668        Page,
4669        "
4670SELECT id,
4671    created_at,
4672    updated_at,
4673    course_id,
4674    exam_id,
4675    chapter_id,
4676    url_path,
4677    title,
4678    deleted_at,
4679    content,
4680    order_number,
4681    copied_from,
4682    hidden,
4683    page_language_group_id
4684FROM pages p
4685WHERE p.course_id = $1
4686    AND p.page_language_group_id = $2
4687    AND p.deleted_at IS NULL
4688    ",
4689        course_id,
4690        page_language_group_id
4691    )
4692    .fetch_one(&mut *conn)
4693    .await?;
4694    Ok(page)
4695}
4696
4697/// Makes the order numbers and chapter ids to match in the db what's in the page objects
4698/// Assumes that all pages belong to the given course id
4699/// Also assumes the list of pages includes all nondeleted pages in the course, except chapter front pages, which cannot be moved, otherwise we will end up with random order numbers
4700pub async fn reorder_pages(
4701    conn: &mut PgConnection,
4702    pages: &[Page],
4703    course_id: Uuid,
4704) -> ModelResult<()> {
4705    let db_pages =
4706        get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
4707    let chapters = get_course_chapters(conn, course_id).await?;
4708
4709    let mut chapter_pages: HashMap<Option<Uuid>, Vec<&Page>> = HashMap::new();
4710
4711    for page in pages {
4712        chapter_pages.entry(page.chapter_id).or_default().push(page);
4713    }
4714
4715    let mut normalized_pages = Vec::with_capacity(pages.len());
4716
4717    for (_, chapter_pages) in chapter_pages.iter() {
4718        // Sort by order_number and then by id for consistency
4719        let mut sorted_pages = chapter_pages.to_vec();
4720        sorted_pages.sort_by(|a, b| {
4721            a.order_number
4722                .cmp(&b.order_number)
4723                .then_with(|| a.id.cmp(&b.id))
4724        });
4725
4726        // Create normalized pages with sequential order numbers
4727        for (idx, &page) in sorted_pages.iter().enumerate() {
4728            let mut normalized_page = page.clone();
4729            normalized_page.order_number = (idx as i32) + 1; // Start at 1, zero is reserved for chapter front pages which cannot be moved and are not passed to this function
4730            normalized_pages.push(normalized_page);
4731        }
4732    }
4733
4734    let mut tx = conn.begin().await?;
4735    // Pages swap order numbers and, when moved between chapters, paths.
4736    crate::courses::defer_structure_constraints(&mut tx).await?;
4737
4738    // Now update each page to its corrected order_number, using the normalized pages
4739    for page in normalized_pages {
4740        if let Some(matching_db_page) = db_pages.iter().find(|p| p.id == page.id) {
4741            if matching_db_page.chapter_id == page.chapter_id {
4742                // Chapter not changing - just set the corrected order number
4743                sqlx::query!(
4744                    "UPDATE pages SET order_number = $2 WHERE id = $1",
4745                    page.id,
4746                    page.order_number
4747                )
4748                .execute(&mut *tx)
4749                .await?;
4750            } else {
4751                // Chapter changes - handle URL paths and redirections
4752                if let Some(old_chapter_id) = matching_db_page.chapter_id {
4753                    if let Some(new_chapter_id) = page.chapter_id {
4754                        // Moving page to another chapter
4755                        if let Some(old_chapter) = chapters.iter().find(|o| o.id == old_chapter_id)
4756                        {
4757                            if let Some(new_chapter) =
4758                                chapters.iter().find(|o| o.id == new_chapter_id)
4759                            {
4760                                sqlx::query!(
4761                                    "UPDATE pages SET chapter_id = $2, order_number = $3 WHERE pages.id = $1",
4762                                    page.id,
4763                                    new_chapter.id,
4764                                    page.order_number
4765                                )
4766                                .execute(&mut *tx)
4767                                .await?;
4768                                sqlx::query!(
4769                                    "UPDATE exercises SET chapter_id = $2 WHERE page_id = $1 AND deleted_at IS NULL",
4770                                    page.id,
4771                                    new_chapter.id
4772                                )
4773                                .execute(&mut *tx)
4774                                .await?;
4775                                if let Some(segment) = chapters::page_path_segment(
4776                                    &matching_db_page.url_path,
4777                                    old_chapter.chapter_number,
4778                                ) {
4779                                    let new_path =
4780                                        chapters::page_path(new_chapter.chapter_number, segment);
4781                                    set_paths(&mut tx, &[(page.id, &new_path)]).await?;
4782                                }
4783                            } else {
4784                                return Err(model_err!(
4785                                    InvalidRequest,
4786                                    "New chapter not found".to_string()
4787                                ));
4788                            }
4789                        } else {
4790                            return Err(model_err!(
4791                                InvalidRequest,
4792                                "Old chapter not found".to_string()
4793                            ));
4794                        }
4795                    } else {
4796                        // Moving page from a chapter to a top level page
4797                        return Err(model_err!(
4798                            InvalidRequest,
4799                            "Making a chapter page a top level page is not supported yet"
4800                                .to_string()
4801                        ));
4802                    }
4803                } else {
4804                    error!(
4805                        "Cannot move a top level page to a chapter. matching_db_page.chapter_id: {:?} page.chapter_id: {:?}",
4806                        matching_db_page.chapter_id, page.chapter_id
4807                    );
4808                    // Moving page from the top level to a chapter
4809                    return Err(model_err!(
4810                        InvalidRequest,
4811                        "Moving a top level page to a chapter is not supported yet".to_string()
4812                    ));
4813                }
4814            }
4815        } else {
4816            return Err(model_err!(
4817                InvalidRequest,
4818                format!("Page {} does exist in course {}", page.id, course_id)
4819            ));
4820        }
4821    }
4822    tx.commit().await?;
4823    Ok(())
4824}
4825
4826/// Saves new chapter numbers, moving the chapters' page paths along with redirects from the old
4827/// ones, and records the reordered chapters in their history.
4828pub async fn reorder_chapters(
4829    conn: &mut PgConnection,
4830    chapters: &[Chapter],
4831    course_id: Uuid,
4832    author_user_id: Uuid,
4833) -> ModelResult<()> {
4834    let db_chapters = get_course_chapters(conn, course_id).await?;
4835    let mut tx = conn.begin().await?;
4836    crate::courses::defer_structure_constraints(&mut tx).await?;
4837    let mut new_paths = Vec::new();
4838    for chapter in chapters {
4839        let Some(db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id) else {
4840            return Err(model_err!(
4841                InvalidRequest,
4842                "Matching DB chapters not found".to_string()
4843            ));
4844        };
4845        if db_chapter.chapter_number == chapter.chapter_number {
4846            continue;
4847        }
4848        sqlx::query!(
4849            "UPDATE chapters SET chapter_number = $2 WHERE id = $1",
4850            chapter.id,
4851            chapter.chapter_number
4852        )
4853        .execute(&mut *tx)
4854        .await?;
4855        for page in get_chapter_page_paths(&mut tx, chapter.id).await? {
4856            if let Some(segment) =
4857                chapters::page_path_segment(&page.url_path, db_chapter.chapter_number)
4858            {
4859                new_paths.push((
4860                    page.id,
4861                    chapters::page_path(chapter.chapter_number, segment),
4862                ));
4863            }
4864        }
4865    }
4866    let new_paths: Vec<(Uuid, &str)> = new_paths
4867        .iter()
4868        .map(|(id, path)| (*id, path.as_str()))
4869        .collect();
4870    set_paths(&mut tx, &new_paths).await?;
4871
4872    let changed_chapters: Vec<_> = chapters
4873        .iter()
4874        .map(|chapter| {
4875            crate::library::entity_snapshots::EntityKey::new(
4876                crate::library::entity_snapshots::ContentEntityType::Chapter,
4877                chapter.id,
4878            )
4879        })
4880        .collect();
4881    crate::entity_history::record_current_many(
4882        &mut tx,
4883        &changed_chapters,
4884        HistoryAuthor::user(author_user_id),
4885    )
4886    .await?;
4887
4888    tx.commit().await?;
4889    Ok(())
4890}
4891
4892/// Whether the page is its chapter's front page; false for a page outside chapters or one that
4893/// doesn't exist.
4894pub async fn is_chapter_front_page(
4895    conn: &mut PgConnection,
4896    page_id: Uuid,
4897) -> ModelResult<IsChapterFrontPage> {
4898    let chapter = get_chapter_by_page_id(conn, page_id).await.optional()?;
4899    Ok(IsChapterFrontPage {
4900        is_chapter_front_page: chapter
4901            .and_then(|chapter| chapter.front_page_id)
4902            .is_some_and(|id| id == page_id),
4903    })
4904}
4905
4906pub async fn update_page_details(
4907    conn: &mut PgConnection,
4908    page_id: Uuid,
4909    page_details_update: &PageDetailsUpdate,
4910) -> ModelResult<()> {
4911    let mut tx = conn.begin().await?;
4912    sqlx::query!(
4913        "UPDATE pages SET title = $2 WHERE id = $1",
4914        page_id,
4915        page_details_update.title,
4916    )
4917    .execute(&mut *tx)
4918    .await?;
4919    set_paths(&mut tx, &[(page_id, &page_details_update.url_path)]).await?;
4920    tx.commit().await?;
4921    Ok(())
4922}
4923
4924pub async fn get_by_ids_and_visibility(
4925    conn: &mut PgConnection,
4926    ids: &[Uuid],
4927    page_visibility: PageVisibility,
4928) -> ModelResult<Vec<Page>> {
4929    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
4930    let pages = sqlx::query_as!(
4931        Page,
4932        "
4933SELECT id,
4934    created_at,
4935    updated_at,
4936    course_id,
4937    exam_id,
4938    chapter_id,
4939    url_path,
4940    title,
4941    deleted_at,
4942    content,
4943    order_number,
4944    copied_from,
4945    hidden,
4946    page_language_group_id
4947FROM pages
4948WHERE id = ANY($1)
4949    AND hidden IS DISTINCT FROM $2
4950    AND deleted_at IS NULL
4951    ",
4952        ids,
4953        inverse_visibility_filter
4954    )
4955    .fetch_all(conn)
4956    .await?;
4957    Ok(pages)
4958}
4959
4960pub async fn get_by_ids_deleted_and_visibility(
4961    conn: &mut PgConnection,
4962    ids: &[Uuid],
4963    page_visibility: PageVisibility,
4964) -> ModelResult<Vec<Page>> {
4965    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
4966    let pages = sqlx::query_as!(
4967        Page,
4968        "
4969SELECT id,
4970    created_at,
4971    updated_at,
4972    course_id,
4973    exam_id,
4974    chapter_id,
4975    url_path,
4976    title,
4977    deleted_at,
4978    content,
4979    order_number,
4980    copied_from,
4981    hidden,
4982    page_language_group_id
4983FROM pages
4984WHERE id = ANY($1)
4985    AND hidden IS DISTINCT FROM $2
4986    AND deleted_at IS NOT NULL
4987    ",
4988        ids,
4989        inverse_visibility_filter
4990    )
4991    .fetch_all(conn)
4992    .await?;
4993    Ok(pages)
4994}
4995
4996#[cfg(test)]
4997mod test {
4998    use chrono::TimeZone;
4999
5000    use super::*;
5001    use crate::{exams::NewExam, test_helper::*};
5002
5003    #[test]
5004    fn normalizes_decoded_page_paths_for_storage_and_lookup() {
5005        // Unsafe ASCII is stripped (not percent-encoded); whitespace becomes a dash.
5006        assert_eq!(
5007            normalize_url_path_for_storage("/foo bar#part"),
5008            "/foo-barpart"
5009        );
5010        assert_eq!(
5011            normalize_url_path_for_storage("/foo%20bar%23part"),
5012            "/foo-barpart"
5013        );
5014        // A literal (undecodable) percent is part of the unsafe set, so it is stripped too.
5015        assert_eq!(
5016            normalize_url_path_for_storage(" /literal%percent "),
5017            "/literalpercent"
5018        );
5019        assert_eq!(
5020            normalize_url_path_for_storage(" /literal%25percent "),
5021            "/literalpercent"
5022        );
5023    }
5024
5025    #[test]
5026    fn keeps_non_ascii_characters_decoded_for_storage() {
5027        // Valid non-ASCII (Cyrillic) is kept verbatim, regardless of whether the input
5028        // arrives decoded or percent-encoded.
5029        assert_eq!(
5030            normalize_url_path_for_storage("/chapter-1/як"),
5031            "/chapter-1/як"
5032        );
5033        assert_eq!(
5034            normalize_url_path_for_storage("/chapter-1/%D1%8F%D0%BA"),
5035            "/chapter-1/як"
5036        );
5037        // Unsafe ASCII mixed with non-ASCII: the space becomes a dash, the non-ASCII is kept.
5038        assert_eq!(
5039            normalize_url_path_for_storage("/chapter 1/як"),
5040            "/chapter-1/як"
5041        );
5042    }
5043
5044    #[test]
5045    fn drops_invalid_percent_escape_runs_like_the_migration() {
5046        // A `%xx` run that isn't valid UTF-8 is dropped whole (matching safe_percent_decode), not
5047        // turned into U+FFFD, so storage and lookup stay aligned.
5048        assert_eq!(normalize_url_path_for_storage("/%FF"), "/");
5049        assert_eq!(normalize_url_path_for_storage("/%41%FF"), "/");
5050    }
5051
5052    #[test]
5053    fn legacy_fully_encoded_form_encodes_non_ascii() {
5054        assert_eq!(
5055            legacy_fully_encoded_url_path("/chapter-1/як"),
5056            "/chapter-1/%D1%8F%D0%BA"
5057        );
5058        assert_eq!(
5059            legacy_fully_encoded_url_path("/chapter-1/%D1%8F%D0%BA"),
5060            "/chapter-1/%D1%8F%D0%BA"
5061        );
5062    }
5063
5064    #[test]
5065    fn legacy_partially_encoded_form_encodes_only_unsafe_ascii() {
5066        // Unsafe ASCII percent-encoded, non-ASCII kept decoded — the pre-strip storage form.
5067        assert_eq!(
5068            legacy_partially_encoded_url_path("/chapter 1/як"),
5069            "/chapter%201/як"
5070        );
5071    }
5072
5073    #[test]
5074    fn lookup_candidates_cover_stored_forms() {
5075        // ASCII-only safe paths have a single candidate (all forms are identical).
5076        assert_eq!(
5077            url_path_lookup_candidates("/chapter-1/foo"),
5078            vec!["/chapter-1/foo".to_string()]
5079        );
5080        // Pure non-ASCII paths: strip-canonical (== partially-encoded) plus the fully-encoded
5081        // legacy form.
5082        assert_eq!(
5083            url_path_lookup_candidates("/chapter-1/як"),
5084            vec![
5085                "/chapter-1/як".to_string(),
5086                "/chapter-1/%D1%8F%D0%BA".to_string(),
5087            ]
5088        );
5089        // A path mixing unsafe ASCII and non-ASCII has three distinct stored forms to try.
5090        assert_eq!(
5091            url_path_lookup_candidates("/chapter 1/як"),
5092            vec![
5093                "/chapter-1/як".to_string(),
5094                "/chapter%201/як".to_string(),
5095                "/chapter%201/%D1%8F%D0%BA".to_string(),
5096            ]
5097        );
5098    }
5099
5100    #[tokio::test]
5101    async fn finds_pages_with_non_ascii_paths_in_either_stored_form() {
5102        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter);
5103
5104        let new_page = |url_path: &str, title: &str| NewPage {
5105            exercises: vec![],
5106            exercise_slides: vec![],
5107            exercise_tasks: vec![],
5108            content: vec![],
5109            url_path: url_path.to_string(),
5110            title: title.to_string(),
5111            course_id: Some(course),
5112            exam_id: None,
5113            chapter_id: Some(chapter),
5114            front_page_of_chapter_id: None,
5115            content_search_language: None,
5116            hidden: false,
5117        };
5118
5119        // Stored in the decoded-canonical form (raw Cyrillic), as insert_page now stores it.
5120        let raw_page = crate::pages::insert_page(
5121            tx.as_mut(),
5122            new_page("/chapter-1/як", "Raw"),
5123            user,
5124            |_, _, _| unimplemented!(),
5125            |_| unimplemented!(),
5126        )
5127        .await
5128        .unwrap();
5129
5130        // Stored in the legacy fully-encoded form, simulating data written before the
5131        // decoded-canonical form was adopted (insert_page normalizes, so update directly).
5132        let legacy_page = crate::pages::insert_page(
5133            tx.as_mut(),
5134            new_page("/chapter-1/іспит", "Legacy"),
5135            user,
5136            |_, _, _| unimplemented!(),
5137            |_| unimplemented!(),
5138        )
5139        .await
5140        .unwrap();
5141        write_paths(
5142            tx.as_mut(),
5143            &[legacy_page.id],
5144            &["/chapter-1/%D1%96%D1%81%D0%BF%D0%B8%D1%82".to_string()],
5145        )
5146        .await
5147        .unwrap();
5148
5149        // Looks the page up through the same helper the production lookup uses, so a
5150        // regression in the candidate matching is caught here.
5151        async fn lookup(
5152            conn: &mut sqlx::PgConnection,
5153            course_id: uuid::Uuid,
5154            requested: &str,
5155        ) -> Option<uuid::Uuid> {
5156            super::find_page_by_requested_path(conn, course_id, requested)
5157                .await
5158                .unwrap()
5159                .map(|page| page.id)
5160        }
5161
5162        // The raw-Cyrillic page resolves whether the request arrives decoded or encoded.
5163        assert_eq!(
5164            lookup(tx.as_mut(), course, "/chapter-1/як").await,
5165            Some(raw_page.id)
5166        );
5167        assert_eq!(
5168            lookup(tx.as_mut(), course, "/chapter-1/%D1%8F%D0%BA").await,
5169            Some(raw_page.id)
5170        );
5171
5172        // The legacy fully-encoded page resolves via the legacy lookup candidate, whether
5173        // the request arrives decoded or encoded.
5174        assert_eq!(
5175            lookup(tx.as_mut(), course, "/chapter-1/іспит").await,
5176            Some(legacy_page.id)
5177        );
5178        assert_eq!(
5179            lookup(
5180                tx.as_mut(),
5181                course,
5182                "/chapter-1/%D1%96%D1%81%D0%BF%D0%B8%D1%82"
5183            )
5184            .await,
5185            Some(legacy_page.id)
5186        );
5187    }
5188
5189    #[tokio::test]
5190    async fn gets_organization_id() {
5191        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, :page);
5192
5193        let course_page_org = get_organization_id(tx.as_mut(), page).await.unwrap();
5194        assert_eq!(org, course_page_org);
5195
5196        let new_exam_id = crate::exams::insert(
5197            tx.as_mut(),
5198            PKeyPolicy::Generate,
5199            &NewExam {
5200                name: "name".to_string(),
5201                starts_at: None,
5202                ends_at: None,
5203                time_minutes: 120,
5204                organization_id: org,
5205                minimum_points_treshold: 24,
5206                grade_manually: false,
5207            },
5208        )
5209        .await
5210        .unwrap();
5211        let page = crate::pages::insert_page(
5212            tx.as_mut(),
5213            NewPage {
5214                exercises: vec![],
5215                exercise_slides: vec![],
5216                exercise_tasks: vec![],
5217                content: vec![],
5218                url_path: "url".to_string(),
5219                title: "title".to_string(),
5220                course_id: None,
5221                exam_id: Some(new_exam_id),
5222                chapter_id: None,
5223                front_page_of_chapter_id: None,
5224                content_search_language: None,
5225                hidden: false,
5226            },
5227            user,
5228            |_, _, _| unimplemented!(),
5229            |_| unimplemented!(),
5230        )
5231        .await
5232        .unwrap();
5233        let exam_page_org = get_organization_id(tx.as_mut(), page.id).await.unwrap();
5234        assert_eq!(org, exam_page_org);
5235    }
5236
5237    #[tokio::test]
5238    async fn page_update_validation_works() {
5239        let e1 = CmsPageExercise {
5240            id: Uuid::parse_str("0c9dca80-5904-4d35-a945-8c080446f667").unwrap(),
5241            name: "".to_string(),
5242            order_number: 1,
5243            score_maximum: 1,
5244            max_tries_per_slide: None,
5245            limit_number_of_tries: false,
5246            deadline: Some(Utc.with_ymd_and_hms(2125, 1, 1, 23, 59, 59).unwrap()),
5247            needs_peer_review: false,
5248            needs_self_review: false,
5249            peer_or_self_review_config: None,
5250            peer_or_self_review_questions: None,
5251            use_course_default_peer_or_self_review_config: false,
5252            teacher_reviews_answer_after_locking: true,
5253        };
5254        let e1_s1 = CmsPageExerciseSlide {
5255            id: Uuid::parse_str("43380e81-6ff2-4f46-9f38-af0ac6a8421a").unwrap(),
5256            exercise_id: e1.id,
5257            order_number: 1,
5258        };
5259        let e1_s1_t1 = CmsPageExerciseTask {
5260            id: Uuid::parse_str("6fb19c22-bca0-42cf-8be5-4141e21cc7a9").unwrap(),
5261            exercise_slide_id: e1_s1.id,
5262            assignment: serde_json::json!([]),
5263            exercise_type: "exercise".to_string(),
5264            private_spec: None,
5265            order_number: 1,
5266            private_spec_files: vec![],
5267        };
5268
5269        // Works without exercises
5270        assert!(
5271            create_update(vec![], vec![], vec![])
5272                .validate_exercise_data()
5273                .is_ok()
5274        );
5275
5276        // Works with single valid exercise
5277        assert!(
5278            create_update(
5279                vec![e1.clone()],
5280                vec![e1_s1.clone()],
5281                vec![e1_s1_t1.clone()],
5282            )
5283            .validate_exercise_data()
5284            .is_ok()
5285        );
5286
5287        // Fails with missing slide
5288        assert!(
5289            create_update(vec![e1.clone()], vec![], vec![e1_s1_t1],)
5290                .validate_exercise_data()
5291                .is_err()
5292        );
5293
5294        // Fails with missing task
5295        assert!(
5296            create_update(vec![e1], vec![e1_s1], vec![],)
5297                .validate_exercise_data()
5298                .is_err()
5299        );
5300    }
5301
5302    fn create_update(
5303        exercises: Vec<CmsPageExercise>,
5304        exercise_slides: Vec<CmsPageExerciseSlide>,
5305        exercise_tasks: Vec<CmsPageExerciseTask>,
5306    ) -> CmsPageUpdate {
5307        CmsPageUpdate {
5308            content: vec![],
5309            exercises,
5310            exercise_slides,
5311            exercise_tasks,
5312            url_path: "".to_string(),
5313            title: "".to_string(),
5314            chapter_id: None,
5315            hidden: false,
5316        }
5317    }
5318
5319    fn lock_chapter_test_block(hidden_text: &str) -> GutenbergBlock {
5320        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
5321            "moocfi/lock-chapter",
5322            headless_lms_utils::attributes! {},
5323            vec![GutenbergBlock::paragraph(hidden_text)],
5324        )
5325    }
5326
5327    fn first_lock_chapter_inner_block_count(page: &Page) -> usize {
5328        page.blocks_cloned().unwrap()[0].inner_blocks.len()
5329    }
5330
5331    /// Updates chapter locking on a course for test setup.
5332    async fn set_course_chapter_locking_enabled(
5333        tx: &mut PgConnection,
5334        course_id: Uuid,
5335        chapter_locking_enabled: bool,
5336    ) {
5337        let app_config = init_app_conf().expect("Application Configuration initialization failed");
5338        let course = courses::get_course(tx, course_id).await.unwrap();
5339        let live = crate::library::entity_snapshots::course_settings_of(tx, &course)
5340            .await
5341            .unwrap();
5342        courses::write_settings(
5343            tx,
5344            &app_config,
5345            &crate::library::entity_snapshots::CourseSettingsSnapshot {
5346                chapter_locking_enabled,
5347                ..live.clone()
5348            },
5349            &live,
5350            false,
5351            &crate::courses::TextEmbeddings::default(),
5352        )
5353        .await
5354        .unwrap();
5355    }
5356
5357    #[test]
5358    fn sanitized_searchable_text_for_public_page_hides_lock_chapter_inner_blocks() {
5359        let content = serde_json::to_value(vec![
5360            GutenbergBlock::paragraph("Visible introduction"),
5361            lock_chapter_test_block("Model solution"),
5362        ])
5363        .unwrap();
5364
5365        let searchable_text = sanitized_searchable_text_for_public_page(&content);
5366
5367        assert!(searchable_text.contains("Visible introduction"));
5368        assert!(!searchable_text.contains("Model solution"));
5369    }
5370
5371    #[tokio::test]
5372    async fn filter_course_material_pages_hides_lock_chapter_inner_blocks_until_reviews_complete() {
5373        insert_data!(
5374            :tx,
5375            :user,
5376            :org,
5377            :course,
5378            instance: _instance,
5379            :course_module,
5380            chapter: chapter_id,
5381            page: page_id,
5382            exercise: exercise_id,
5383            slide: _exercise_slide_id,
5384            task: _exercise_task_id
5385        );
5386        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
5387
5388        update_page_content(
5389            tx.as_mut(),
5390            page_id,
5391            &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
5392        )
5393        .await
5394        .unwrap();
5395
5396        crate::exercises::update_teacher_reviews_answer_after_locking(
5397            tx.as_mut(),
5398            exercise_id,
5399            true,
5400        )
5401        .await
5402        .unwrap();
5403        crate::user_exercise_states::get_or_create_user_exercise_state(
5404            tx.as_mut(),
5405            user,
5406            exercise_id,
5407            Some(course),
5408            None,
5409        )
5410        .await
5411        .unwrap();
5412        crate::user_chapter_locking_statuses::complete_and_lock_chapter(
5413            tx.as_mut(),
5414            user,
5415            chapter_id,
5416            course,
5417        )
5418        .await
5419        .unwrap();
5420        crate::user_exercise_states::update_reviewing_stage(
5421            tx.as_mut(),
5422            user,
5423            CourseOrExamId::Course(course),
5424            exercise_id,
5425            crate::user_exercise_states::ReviewingStage::WaitingForManualGrading,
5426        )
5427        .await
5428        .unwrap();
5429
5430        let page = get_page(tx.as_mut(), page_id).await.unwrap();
5431        let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
5432            .await
5433            .unwrap();
5434        assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 0);
5435
5436        crate::user_exercise_states::update_reviewing_stage(
5437            tx.as_mut(),
5438            user,
5439            CourseOrExamId::Course(course),
5440            exercise_id,
5441            crate::user_exercise_states::ReviewingStage::ReviewedAndLocked,
5442        )
5443        .await
5444        .unwrap();
5445
5446        let page = get_page(tx.as_mut(), page_id).await.unwrap();
5447        let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
5448            .await
5449            .unwrap();
5450        assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 1);
5451    }
5452
5453    #[tokio::test]
5454    async fn filter_course_material_pages_with_exercises_hides_lock_chapter_inner_blocks() {
5455        insert_data!(
5456            :tx,
5457            :user,
5458            :org,
5459            :course,
5460            instance: _instance,
5461            :course_module,
5462            chapter: _chapter_id,
5463            page: page_id
5464        );
5465
5466        update_page_content(
5467            tx.as_mut(),
5468            page_id,
5469            &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
5470        )
5471        .await
5472        .unwrap();
5473
5474        let page = get_page(tx.as_mut(), page_id).await.unwrap();
5475        let filtered_pages = filter_course_material_pages_with_exercises(
5476            tx.as_mut(),
5477            None,
5478            vec![PageWithExercises {
5479                page,
5480                exercises: vec![],
5481            }],
5482        )
5483        .await
5484        .unwrap();
5485
5486        assert_eq!(
5487            first_lock_chapter_inner_block_count(&filtered_pages[0].page),
5488            0
5489        );
5490    }
5491
5492    #[tokio::test]
5493    async fn filter_course_material_pages_does_not_initialize_lock_status_when_course_locking_is_disabled()
5494     {
5495        insert_data!(
5496            :tx,
5497            :user,
5498            :org,
5499            :course,
5500            instance: _instance,
5501            :course_module,
5502            chapter: chapter_id,
5503            page: page_id
5504        );
5505
5506        set_course_chapter_locking_enabled(tx.as_mut(), course, false).await;
5507
5508        update_page_content(
5509            tx.as_mut(),
5510            page_id,
5511            &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
5512        )
5513        .await
5514        .unwrap();
5515
5516        let status = user_chapter_locking_statuses::get_or_init_status(
5517            tx.as_mut(),
5518            user,
5519            chapter_id,
5520            Some(course),
5521            Some(false),
5522        )
5523        .await
5524        .unwrap();
5525
5526        let page = get_page(tx.as_mut(), page_id).await.unwrap();
5527        let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
5528            .await
5529            .unwrap();
5530
5531        assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 0);
5532
5533        assert!(status.is_none());
5534    }
5535
5536    #[tokio::test]
5537    async fn page_search_results_do_not_leak_hidden_lock_chapter_snippet_text() {
5538        insert_data!(
5539            :tx,
5540            :user,
5541            :org,
5542            :course,
5543            instance: _instance,
5544            :course_module,
5545            chapter: _chapter_id,
5546            page: page_id
5547        );
5548
5549        update_page_content(
5550            tx.as_mut(),
5551            page_id,
5552            &serde_json::to_value(vec![
5553                GutenbergBlock::paragraph("Visible introduction"),
5554                lock_chapter_test_block("Model solution"),
5555            ])
5556            .unwrap(),
5557        )
5558        .await
5559        .unwrap();
5560
5561        let results = get_page_search_results_for_words(
5562            tx.as_mut(),
5563            course,
5564            &SearchRequest {
5565                query: "Visible".to_string(),
5566            },
5567        )
5568        .await
5569        .unwrap();
5570
5571        let result = results
5572            .iter()
5573            .find(|result| result.id == page_id)
5574            .expect("page should match visible content query");
5575        let content_headline = result.content_headline.as_deref().unwrap_or_default();
5576
5577        assert!(!content_headline.contains("Model solution"));
5578    }
5579
5580    #[tokio::test]
5581    async fn page_upsert_peer_reviews_work() {
5582        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
5583        let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
5584        let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
5585        let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
5586            .await
5587            .unwrap();
5588
5589        let pr1 = CmsPeerOrSelfReviewConfig {
5590            id:pr_id,
5591            exercise_id: Some(exercise_id),
5592            course_id: course,
5593            processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
5594            accepting_threshold: 0.5,
5595            peer_reviews_to_give: 2,
5596            peer_reviews_to_receive: 1,
5597            points_are_all_or_nothing: false,
5598            reset_answer_if_zero_points_from_review: false,
5599            review_instructions: None,
5600        };
5601        let prq = CmsPeerOrSelfReviewQuestion {
5602            id: prq_id,
5603            peer_or_self_review_config_id: pr_id,
5604            answer_required: true,
5605            order_number: 0,
5606            question: "juu".to_string(),
5607            question_type:
5608                crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
5609            weight: 0.31,
5610        };
5611        let mut remapped_exercises = HashMap::new();
5612        remapped_exercises.insert(exercise_id, exercise);
5613        let pr_res = upsert_peer_or_self_review_configs(
5614            tx.as_mut(),
5615            &[],
5616            &[pr1],
5617            &remapped_exercises,
5618            false,
5619        )
5620        .await
5621        .unwrap();
5622        let prq_res =
5623            upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, false)
5624                .await
5625                .unwrap();
5626
5627        assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
5628
5629        assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
5630        assert!(!pr_res.get(&pr_id).unwrap().points_are_all_or_nothing);
5631        assert_eq!(prq_res.get(&prq_id).unwrap().weight, 0.31);
5632    }
5633
5634    #[tokio::test]
5635    async fn page_upsert_peer_reviews_work_retain_ids() {
5636        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
5637        let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
5638            .await
5639            .unwrap();
5640        let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
5641        let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
5642        let pr1 = CmsPeerOrSelfReviewConfig {
5643            id:pr_id,
5644            exercise_id: Some(exercise_id),
5645            course_id: course,
5646            processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
5647            accepting_threshold: 0.5,
5648            peer_reviews_to_give: 2,
5649            peer_reviews_to_receive: 1,
5650            points_are_all_or_nothing: true,
5651            reset_answer_if_zero_points_from_review: false,
5652            review_instructions: None,
5653        };
5654        let prq = CmsPeerOrSelfReviewQuestion {
5655            id: prq_id,
5656            peer_or_self_review_config_id: pr_id,
5657            answer_required: true,
5658            order_number: 0,
5659            question: "juu".to_string(),
5660            question_type:
5661                crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
5662            weight: 0.0,
5663        };
5664        let mut remapped_exercises = HashMap::new();
5665        remapped_exercises.insert(exercise_id, exercise);
5666        let pr_res =
5667            upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[pr1], &remapped_exercises, true)
5668                .await
5669                .unwrap();
5670        let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, true)
5671            .await
5672            .unwrap();
5673
5674        assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
5675        assert!(pr_res.get(&pr_id).unwrap().id == pr_id);
5676
5677        assert!(prq_res.get(&prq_id).unwrap().id == prq_id);
5678        assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
5679    }
5680
5681    #[tokio::test]
5682    async fn page_upsert_peer_reviews_work_empty() {
5683        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
5684        let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
5685            .await
5686            .unwrap();
5687        let mut remapped_exercises = HashMap::new();
5688        remapped_exercises.insert(exercise_id, exercise);
5689        let pr_res =
5690            upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[], &remapped_exercises, true)
5691                .await
5692                .unwrap();
5693        let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[], &pr_res, true)
5694            .await
5695            .unwrap();
5696
5697        assert!(pr_res.is_empty());
5698        assert!(prq_res.is_empty());
5699    }
5700
5701    #[tokio::test]
5702    async fn reorder_top_level_pages_works() {
5703        insert_data!(:tx, :user, :org, :course);
5704
5705        // First, delete any existing pages in this course to ensure a clean slate, except the
5706        // course front page, which cannot be deleted.
5707        let existing_pages =
5708            get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
5709                .await
5710                .unwrap();
5711        for page in existing_pages.iter().filter(|p| p.url_path.trim() != "/") {
5712            delete_page_and_exercises(tx.as_mut(), page.id, HistoryAuthor::user(user))
5713                .await
5714                .unwrap();
5715        }
5716
5717        // Create our test pages. Order numbers start at 1: the course front page (not deleted
5718        // above) already occupies order_number 0.
5719        let page1 = NewCoursePage::new(course, 1, "top-page-1", "Top Page 1");
5720        let (page1_id, _) = insert_course_page(tx.as_mut(), &page1, user).await.unwrap();
5721        let page2 = NewCoursePage::new(course, 2, "top-page-2", "Top Page 2");
5722        let (page2_id, _) = insert_course_page(tx.as_mut(), &page2, user).await.unwrap();
5723        let page3 = NewCoursePage::new(course, 3, "top-page-3", "Top Page 3");
5724        let (page3_id, _) = insert_course_page(tx.as_mut(), &page3, user).await.unwrap();
5725
5726        // The frontend never sends front pages to reorder_pages.
5727        let mut pages: Vec<_> =
5728            get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
5729                .await
5730                .unwrap()
5731                .into_iter()
5732                .filter(|p| p.url_path.trim() != "/")
5733                .collect();
5734
5735        let page1_index = pages.iter().position(|p| p.id == page1_id).unwrap();
5736        let page2_index = pages.iter().position(|p| p.id == page2_id).unwrap();
5737        let page3_index = pages.iter().position(|p| p.id == page3_id).unwrap();
5738
5739        pages[page1_index].order_number = 2;
5740        pages[page3_index].order_number = 1;
5741        pages[page2_index].order_number = 3;
5742
5743        // Apply the reordering
5744        reorder_pages(tx.as_mut(), &pages, course).await.unwrap();
5745
5746        // Check that the reordering took effect
5747        let page1_updated = get_page(tx.as_mut(), page1_id).await.unwrap();
5748        let page2_updated = get_page(tx.as_mut(), page2_id).await.unwrap();
5749        let page3_updated = get_page(tx.as_mut(), page3_id).await.unwrap();
5750
5751        assert_eq!(page1_updated.order_number, 2);
5752        assert_eq!(page2_updated.order_number, 3);
5753        assert_eq!(page3_updated.order_number, 1);
5754    }
5755
5756    fn declaring_task() -> NormalizedCmsExerciseTask {
5757        NormalizedCmsExerciseTask {
5758            id: Uuid::new_v4(),
5759            assignment: serde_json::Value::Null,
5760            exercise_type: "declaring-service".to_string(),
5761            private_spec: Some(serde_json::json!({ "prompt": "hi" })),
5762        }
5763    }
5764
5765    async fn derive_from_declaring_service(body: serde_json::Value) -> ModelResult<DerivedSpec> {
5766        let exercise_type = "declaring-service".to_string();
5767        let mut urls = HashMap::new();
5768        urls.insert(
5769            &exercise_type,
5770            Url::parse("http://example.com/spec").unwrap(),
5771        );
5772        fetch_derived_spec(
5773            None,
5774            &declaring_task(),
5775            &urls,
5776            move |_url, _slug, _spec| {
5777                let body = body.clone();
5778                Box::pin(async move { Ok(body) })
5779            },
5780            None,
5781            Uuid::new_v4(),
5782            true,
5783        )
5784        .await
5785    }
5786
5787    #[tokio::test]
5788    async fn reads_the_spec_and_its_files_a_declaring_service_answers_with() {
5789        let file = Uuid::new_v4();
5790        let derived = derive_from_declaring_service(serde_json::json!({
5791            "spec": { "prompt": "hi" },
5792            "files": [file],
5793        }))
5794        .await
5795        .unwrap();
5796
5797        assert_eq!(derived.spec, Some(serde_json::json!({ "prompt": "hi" })));
5798        assert_eq!(derived.declared_files, Some(vec![file]));
5799    }
5800
5801    #[tokio::test]
5802    async fn reads_a_null_spec_a_declaring_service_has_nothing_to_show_for() {
5803        let derived = derive_from_declaring_service(serde_json::json!({
5804            "spec": null,
5805            "files": [],
5806        }))
5807        .await
5808        .unwrap();
5809
5810        assert_eq!(derived.spec, None);
5811        assert_eq!(derived.declared_files, Some(vec![]));
5812    }
5813
5814    #[tokio::test]
5815    async fn refuses_a_bare_spec_from_a_declaring_service() {
5816        // The dangerous shape: a lenient parse reads a bare spec as "no spec, no files".
5817        derive_from_declaring_service(serde_json::json!({ "prompt": "hi", "options": [] }))
5818            .await
5819            .expect_err("a bare spec is not an envelope");
5820        derive_from_declaring_service(serde_json::json!({ "files": [] }))
5821            .await
5822            .expect_err("an envelope without its spec key is not an envelope");
5823    }
5824}