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