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