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::{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},
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<Course>,
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: 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),
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    let raw_results = sqlx::query_as::<_, RawPageSearchResult>(&search_results_sql)
3299        .bind(course_id)
3300        .bind(content_search_language)
3301        .bind(&page_search_request.query)
3302        .bind(&last_word)
3303        .fetch_all(&mut *conn)
3304        .await?;
3305
3306    let sanitized_search_texts = raw_results
3307        .iter()
3308        .map(|result| sanitized_searchable_text_for_public_page(&result.content))
3309        .collect::<Vec<_>>();
3310    let content_headlines = build_public_search_content_headlines(
3311        conn,
3312        content_search_language,
3313        &page_search_request.query,
3314        &last_word,
3315        query_type,
3316        &sanitized_search_texts,
3317    )
3318    .await?;
3319
3320    let search_results = raw_results
3321        .into_iter()
3322        .zip(content_headlines)
3323        .map(|(result, content_headline)| PageSearchResult {
3324            id: result.id,
3325            title_headline: result.title_headline,
3326            rank: result.rank,
3327            content_headline,
3328            url_path: result.url_path,
3329            chapter_name: result.chapter_name,
3330        })
3331        .collect::<Vec<_>>();
3332
3333    Ok(add_course_url_prefix_to_search_results(
3334        search_results,
3335        &course,
3336    ))
3337}
3338
3339async fn build_public_search_content_headlines(
3340    conn: &mut PgConnection,
3341    content_search_language: &str,
3342    query: &str,
3343    last_word: &str,
3344    query_type: PageSearchQueryType,
3345    sanitized_search_texts: &[String],
3346) -> ModelResult<Vec<Option<String>>> {
3347    if sanitized_search_texts.is_empty() {
3348        return Ok(Vec::new());
3349    }
3350
3351    let query_builder = match query_type {
3352        PageSearchQueryType::Phrase => "phraseto_tsquery",
3353        PageSearchQueryType::Words => "plainto_tsquery",
3354    };
3355    let query_builder_comment = match query_type {
3356        PageSearchQueryType::Phrase =>
3357            "-- Converts the search term to a phrase search with phraseto_tsquery but appends ':*' to the last word so that it
3358    -- becomes a prefix match. This way the search will also contain results when the last word in the search term
3359    -- is only partially typed. Note that if to_tsquery($3) decides to stem the word, the replacement will be skipped.",
3360        PageSearchQueryType::Words =>
3361            "-- Converts the search term to a word search with ands between the words with plainto_tsquery but appends ':*' to the
3362    -- last word so that it becomes a prefix match. This way the search will also contain results when the last word in
3363    -- the search term is only partially typed. Note that if to_tsquery($3) decides to stem the word, the replacement
3364    -- will be skipped.",
3365    };
3366
3367    let content_headline_sql = format!(
3368        r#"
3369WITH cte as (
3370    {query_builder_comment}
3371    SELECT ts_rewrite(
3372        {query_builder}($1::regconfig, $2),
3373        to_tsquery($3),
3374        to_tsquery($3 || ':*')
3375    ) as query
3376)
3377SELECT ts_headline(
3378    $1::regconfig,
3379    input.content,
3380    (
3381        SELECT query
3382        from cte
3383    ),
3384    'MaxFragments=1, MaxWords=30, MinWords=10'
3385) as content_headline
3386FROM unnest($4::text[]) WITH ORDINALITY AS input(content, ord)
3387ORDER BY ord;
3388        "#
3389    );
3390
3391    Ok(
3392        sqlx::query_as::<_, SearchContentHeadlineRow>(&content_headline_sql)
3393            .bind(content_search_language)
3394            .bind(query)
3395            .bind(last_word)
3396            .bind(sanitized_search_texts.to_vec())
3397            .fetch_all(&mut *conn)
3398            .await?
3399            .into_iter()
3400            .map(|row| row.content_headline)
3401            .collect(),
3402    )
3403}
3404
3405fn add_course_url_prefix_to_search_results(
3406    search_results: Vec<PageSearchResult>,
3407    course: &Course,
3408) -> Vec<PageSearchResult> {
3409    search_results
3410        .into_iter()
3411        .map(|mut sr| {
3412            let optional_slash = if sr.url_path.starts_with('/') {
3413                ""
3414            } else {
3415                "/"
3416            };
3417            sr.url_path = format!("/{}{}{}", course.slug, optional_slash, sr.url_path);
3418            sr
3419        })
3420        .collect()
3421}
3422
3423/// Restore page contents and exercises to a previous revision
3424pub async fn restore(
3425    conn: &mut PgConnection,
3426    page_id: Uuid,
3427    history_id: Uuid,
3428    author: Uuid,
3429    spec_fetcher: impl SpecFetcher,
3430    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
3431) -> ModelResult<Uuid> {
3432    // fetch old content
3433    let page = get_page(conn, page_id).await?;
3434    let history_data = page_history::get_history_data(conn, history_id).await?;
3435
3436    let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(history_data.content.content)?;
3437
3438    update_page(
3439        conn,
3440        PageUpdateArgs {
3441            page_id: page.id,
3442            author,
3443            cms_page_update: CmsPageUpdate {
3444                content: parsed_content,
3445                exercises: history_data.content.exercises,
3446                exercise_slides: history_data.content.exercise_slides,
3447                exercise_tasks: history_data.content.exercise_tasks,
3448                url_path: page.url_path,
3449                title: history_data.title,
3450                chapter_id: page.chapter_id,
3451            },
3452            retain_ids: true,
3453            history_change_reason: HistoryChangeReason::HistoryRestored,
3454            is_exam_page: history_data.exam_id.is_some(),
3455        },
3456        spec_fetcher,
3457        fetch_service_info,
3458    )
3459    .await?;
3460
3461    Ok(history_id)
3462}
3463
3464#[allow(clippy::too_many_arguments)]
3465pub async fn restore_from_history_for_page_id(
3466    conn: &mut PgConnection,
3467    page_id: Uuid,
3468    history_id: Uuid,
3469    authorized_source_page_id: Option<Uuid>,
3470    expected_course_id: Option<Uuid>,
3471    expected_exam_id: Option<Uuid>,
3472    author: Uuid,
3473    spec_fetcher: impl SpecFetcher,
3474    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
3475) -> ModelResult<Uuid> {
3476    let mut tx = conn.begin().await?;
3477    let validated_source_page_id = if let Some(source_page_id) = authorized_source_page_id {
3478        let source_page = sqlx::query!(
3479            r#"
3480SELECT p.id
3481FROM pages p
3482WHERE p.id = $1
3483  AND p.deleted_at IS NULL
3484  AND (p.course_id = $2 OR p.exam_id = $3)
3485FOR UPDATE
3486            "#,
3487            source_page_id,
3488            expected_course_id,
3489            expected_exam_id
3490        )
3491        .fetch_optional(&mut *tx)
3492        .await?;
3493        source_page.map(|row| row.id)
3494    } else {
3495        None
3496    };
3497
3498    sqlx::query!(
3499        r#"
3500SELECT p.id
3501FROM pages p
3502WHERE p.id = $1
3503  AND p.deleted_at IS NULL
3504  AND (p.course_id = $2 OR p.exam_id = $3)
3505FOR UPDATE
3506        "#,
3507        page_id,
3508        expected_course_id,
3509        expected_exam_id
3510    )
3511    .fetch_one(&mut *tx)
3512    .await?;
3513
3514    sqlx::query!(
3515        r#"
3516SELECT ph.id
3517FROM page_history ph
3518WHERE ph.id = $1
3519  AND ph.deleted_at IS NULL
3520  AND (
3521    ph.page_id = $2
3522    OR ph.page_id = $3
3523  )
3524FOR UPDATE
3525        "#,
3526        history_id,
3527        page_id,
3528        validated_source_page_id
3529    )
3530    .fetch_one(&mut *tx)
3531    .await?;
3532
3533    let restored = restore(
3534        &mut tx,
3535        page_id,
3536        history_id,
3537        author,
3538        spec_fetcher,
3539        fetch_service_info,
3540    )
3541    .await?;
3542    tx.commit().await?;
3543    Ok(restored)
3544}
3545
3546pub async fn get_organization_id(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<Uuid> {
3547    let res = sqlx::query!(
3548        "
3549SELECT organizations.id
3550FROM pages
3551  LEFT OUTER JOIN courses ON courses.id = pages.course_id
3552  LEFT OUTER JOIN exams ON exams.id = pages.exam_id
3553  JOIN organizations ON organizations.id = courses.organization_id
3554  OR organizations.id = exams.organization_id
3555WHERE pages.id = $1
3556",
3557        page_id,
3558    )
3559    .fetch_one(&mut *conn)
3560    .await?;
3561    Ok(res.id)
3562}
3563
3564pub async fn get_page_chapter_and_course_information(
3565    conn: &mut PgConnection,
3566    page_id: Uuid,
3567) -> ModelResult<PageChapterAndCourseInformation> {
3568    let res = sqlx::query_as!(
3569        PageChapterAndCourseInformation,
3570        r#"
3571SELECT chapters.name as "chapter_name?",
3572  chapters.chapter_number as "chapter_number?",
3573  courses.name as "course_name?",
3574  courses.slug as "course_slug?",
3575  chapters.front_page_id as "chapter_front_page_id?",
3576  p2.url_path as "chapter_front_page_url_path?",
3577  organizations.slug as organization_slug
3578FROM pages
3579  LEFT JOIN chapters on pages.chapter_id = chapters.id
3580  LEFT JOIN courses on pages.course_id = courses.id
3581  LEFT JOIN pages p2 ON chapters.front_page_id = p2.id
3582  LEFT JOIN organizations on courses.organization_id = organizations.id
3583WHERE pages.id = $1
3584"#,
3585        page_id,
3586    )
3587    .fetch_one(&mut *conn)
3588    .await?;
3589    Ok(res)
3590}
3591
3592pub async fn get_page_by_course_id_and_language_group(
3593    conn: &mut PgConnection,
3594    course_id: Uuid,
3595    page_language_group_id: Uuid,
3596) -> ModelResult<Page> {
3597    let page = sqlx::query_as!(
3598        Page,
3599        "
3600SELECT id,
3601    created_at,
3602    updated_at,
3603    course_id,
3604    exam_id,
3605    chapter_id,
3606    url_path,
3607    title,
3608    deleted_at,
3609    content,
3610    order_number,
3611    copied_from,
3612    hidden,
3613    page_language_group_id
3614FROM pages p
3615WHERE p.course_id = $1
3616    AND p.page_language_group_id = $2
3617    AND p.deleted_at IS NULL
3618    ",
3619        course_id,
3620        page_language_group_id
3621    )
3622    .fetch_one(&mut *conn)
3623    .await?;
3624    Ok(page)
3625}
3626
3627/// Makes the order numbers and chapter ids to match in the db what's in the page objects
3628/// Assumes that all pages belong to the given course id
3629/// 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
3630pub async fn reorder_pages(
3631    conn: &mut PgConnection,
3632    pages: &[Page],
3633    course_id: Uuid,
3634) -> ModelResult<()> {
3635    let db_pages =
3636        get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
3637    let chapters = course_chapters(conn, course_id).await?;
3638
3639    let mut chapter_pages: HashMap<Option<Uuid>, Vec<&Page>> = HashMap::new();
3640
3641    for page in pages {
3642        chapter_pages.entry(page.chapter_id).or_default().push(page);
3643    }
3644
3645    let mut normalized_pages = Vec::with_capacity(pages.len());
3646
3647    for (_, chapter_pages) in chapter_pages.iter() {
3648        // Sort by order_number and then by id for consistency
3649        let mut sorted_pages = chapter_pages.to_vec();
3650        sorted_pages.sort_by(|a, b| {
3651            a.order_number
3652                .cmp(&b.order_number)
3653                .then_with(|| a.id.cmp(&b.id))
3654        });
3655
3656        // Create normalized pages with sequential order numbers
3657        for (idx, &page) in sorted_pages.iter().enumerate() {
3658            let mut normalized_page = page.clone();
3659            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
3660            normalized_pages.push(normalized_page);
3661        }
3662    }
3663
3664    let mut tx = conn.begin().await?;
3665
3666    // First, randomize ALL page order numbers to avoid conflicts (except chapter front pages, which cannot be moved)
3667    // This is necessary because unique indexes cannot be deferred in PostgreSQL
3668    // The random numbers are temporary and will be replaced with the correct order numbers
3669    sqlx::query!(
3670        "
3671UPDATE pages
3672SET order_number = floor(random() * (2000000 -200000 + 1) + 200000)
3673WHERE course_id = $1
3674  AND order_number != 0
3675  AND deleted_at IS NULL
3676        ",
3677        course_id
3678    )
3679    .execute(&mut *tx)
3680    .await?;
3681
3682    // Now update each page to its corrected order_number, using the normalized pages
3683    for page in normalized_pages {
3684        if let Some(matching_db_page) = db_pages.iter().find(|p| p.id == page.id) {
3685            if matching_db_page.chapter_id == page.chapter_id {
3686                // Chapter not changing - just set the corrected order number
3687                sqlx::query!(
3688                    "UPDATE pages SET order_number = $2 WHERE id = $1",
3689                    page.id,
3690                    page.order_number
3691                )
3692                .execute(&mut *tx)
3693                .await?;
3694            } else {
3695                // Chapter changes - handle URL paths and redirections
3696                if let Some(old_chapter_id) = matching_db_page.chapter_id {
3697                    if let Some(new_chapter_id) = page.chapter_id {
3698                        // Moving page to another chapter
3699                        if let Some(old_chapter) = chapters.iter().find(|o| o.id == old_chapter_id)
3700                        {
3701                            if let Some(new_chapter) =
3702                                chapters.iter().find(|o| o.id == new_chapter_id)
3703                            {
3704                                let old_path = &page.url_path;
3705                                let new_path = old_path.replacen(
3706                                    &old_chapter.chapter_number.to_string(),
3707                                    &new_chapter.chapter_number.to_string(),
3708                                    1,
3709                                );
3710                                sqlx::query!(
3711                                    "UPDATE pages SET url_path = $2, chapter_id = $3, order_number = $4 WHERE pages.id = $1",
3712                                    page.id,
3713                                    new_path,
3714                                    new_chapter.id,
3715                                    page.order_number
3716                                )
3717                                .execute(&mut *tx)
3718                                .await?;
3719                                sqlx::query!(
3720                                    "INSERT INTO url_redirections(destination_page_id, old_url_path, course_id) VALUES ($1, $2, $3)",
3721                                    page.id,
3722                                    old_path,
3723                                    course_id
3724                                )
3725                                .execute(&mut *tx)
3726                                .await?;
3727                            } else {
3728                                return Err(model_err!(
3729                                    InvalidRequest,
3730                                    "New chapter not found".to_string()
3731                                ));
3732                            }
3733                        } else {
3734                            return Err(model_err!(
3735                                InvalidRequest,
3736                                "Old chapter not found".to_string()
3737                            ));
3738                        }
3739                    } else {
3740                        // Moving page from a chapter to a top level page
3741                        return Err(model_err!(
3742                            InvalidRequest,
3743                            "Making a chapter page a top level page is not supported yet"
3744                                .to_string()
3745                        ));
3746                    }
3747                } else {
3748                    error!(
3749                        "Cannot move a top level page to a chapter. matching_db_page.chapter_id: {:?} page.chapter_id: {:?}",
3750                        matching_db_page.chapter_id, page.chapter_id
3751                    );
3752                    // Moving page from the top level to a chapter
3753                    return Err(model_err!(
3754                        InvalidRequest,
3755                        "Moving a top level page to a chapter is not supported yet".to_string()
3756                    ));
3757                }
3758            }
3759        } else {
3760            return Err(model_err!(
3761                InvalidRequest,
3762                format!("Page {} does exist in course {}", page.id, course_id)
3763            ));
3764        }
3765    }
3766    tx.commit().await?;
3767    Ok(())
3768}
3769
3770pub async fn reorder_chapters(
3771    conn: &mut PgConnection,
3772    chapters: &[Chapter],
3773    course_id: Uuid,
3774) -> ModelResult<()> {
3775    let db_chapters = course_chapters(conn, course_id).await?;
3776    let mut tx = conn.begin().await?;
3777    // Look for the modified chapter in the existing database
3778
3779    // TODO USE CHAPTER ID FOR THE LOOP
3780    for chapter in chapters {
3781        if let Some(matching_db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id)
3782            && let Some(old_chapter) = db_chapters.iter().find(|o| o.id == matching_db_chapter.id)
3783        {
3784            // to avoid conflicting chapter_number when chapter is modified
3785            //Assign random number to modified chapters
3786            sqlx::query!(
3787                "UPDATE chapters
3788                SET chapter_number = floor(random() * (20000000 - 2000000 + 1) + 200000)
3789                WHERE chapters.id = $1
3790                  AND chapters.course_id = $2
3791                  AND deleted_at IS NULL",
3792                matching_db_chapter.id,
3793                course_id
3794            )
3795            .execute(&mut *tx)
3796            .await?;
3797
3798            // get newly modified chapter
3799            let chapter_with_randomized_chapter_number =
3800                get_chapter(&mut tx, matching_db_chapter.id).await?;
3801            let random_chapter_number = chapter_with_randomized_chapter_number.chapter_number;
3802            let pages =
3803                get_chapter_pages(&mut tx, chapter_with_randomized_chapter_number.id).await?;
3804
3805            for page in pages {
3806                let old_path = &page.url_path;
3807                let new_path = old_path.replacen(
3808                    &old_chapter.chapter_number.to_string(),
3809                    &random_chapter_number.to_string(),
3810                    1,
3811                );
3812
3813                // update each page path associated with a random chapter number
3814                sqlx::query!(
3815                    "UPDATE pages SET url_path = $2 WHERE pages.id = $1",
3816                    page.id,
3817                    new_path
3818                )
3819                .execute(&mut *tx)
3820                .await?;
3821            }
3822        }
3823    }
3824
3825    for chapter in chapters {
3826        if let Some(matching_db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id) {
3827            if let Some(new_chapter) = chapters.iter().find(|o| o.id == matching_db_chapter.id) {
3828                let new_chapter_number = &new_chapter.chapter_number;
3829
3830                let randomized_chapter = get_chapter(&mut tx, chapter.id).await?;
3831
3832                let randomized_chapter_number = randomized_chapter.chapter_number;
3833
3834                // update chapter_number
3835                sqlx::query!(
3836                    "UPDATE chapters SET chapter_number = $2 WHERE chapters.id = $1",
3837                    chapter.id,
3838                    new_chapter_number
3839                )
3840                .execute(&mut *tx)
3841                .await?;
3842
3843                // update all pages url in the modified chapter
3844                let pages = get_chapter_pages(&mut tx, chapter.id).await?;
3845
3846                for page in pages {
3847                    let path_with_temp_random_number = &page.url_path;
3848                    let new_path = path_with_temp_random_number.replacen(
3849                        &randomized_chapter_number.to_string(),
3850                        &new_chapter_number.to_string(),
3851                        1,
3852                    );
3853                    let old_path = path_with_temp_random_number.replacen(
3854                        &randomized_chapter_number.to_string(),
3855                        &chapter.chapter_number.to_string(),
3856                        1,
3857                    );
3858                    // update each page path associated with the modified chapter
3859                    sqlx::query!(
3860                        "UPDATE pages SET url_path = $2 WHERE pages.id = $1",
3861                        page.id,
3862                        new_path
3863                    )
3864                    .execute(&mut *tx)
3865                    .await?;
3866
3867                    crate::url_redirections::upsert(
3868                        &mut tx,
3869                        PKeyPolicy::Generate,
3870                        page.id,
3871                        &old_path,
3872                        course_id,
3873                    )
3874                    .await?;
3875                }
3876            } else {
3877                return Err(model_err!(
3878                    InvalidRequest,
3879                    "New chapter not found".to_string()
3880                ));
3881            }
3882        } else {
3883            return Err(model_err!(
3884                InvalidRequest,
3885                "Matching DB chapters not found".to_string()
3886            ));
3887        }
3888    }
3889
3890    tx.commit().await?;
3891    Ok(())
3892}
3893
3894pub async fn is_chapter_front_page(
3895    conn: &mut PgConnection,
3896    page_id: Uuid,
3897) -> ModelResult<IsChapterFrontPage> {
3898    let chapter = get_chapter_by_page_id(conn, page_id).await?;
3899
3900    Ok(chapter.front_page_id.map_or(
3901        IsChapterFrontPage {
3902            is_chapter_front_page: false,
3903        },
3904        |id| IsChapterFrontPage {
3905            is_chapter_front_page: id == page_id,
3906        },
3907    ))
3908}
3909
3910pub async fn update_page_details(
3911    conn: &mut PgConnection,
3912    page_id: Uuid,
3913    page_details_update: &PageDetailsUpdate,
3914) -> ModelResult<()> {
3915    let normalized_url_path = normalize_url_path_for_storage(&page_details_update.url_path);
3916    let mut tx = conn.begin().await?;
3917    let page_before_update = get_page(&mut tx, page_id).await?;
3918    sqlx::query!(
3919        "
3920UPDATE pages
3921SET title = $2,
3922  url_path = $3
3923WHERE id = $1
3924",
3925        page_id,
3926        page_details_update.title,
3927        normalized_url_path,
3928    )
3929    .execute(&mut *tx)
3930    .await?;
3931
3932    if let Some(course_id) = page_before_update.course_id
3933        && page_before_update.url_path != normalized_url_path
3934    {
3935        // Some students might be trying to reach the page with the old url path, so let's redirect them to the new one
3936        crate::url_redirections::upsert(
3937            &mut tx,
3938            PKeyPolicy::Generate,
3939            page_id,
3940            &page_before_update.url_path,
3941            course_id,
3942        )
3943        .await?;
3944    }
3945
3946    tx.commit().await?;
3947    Ok(())
3948}
3949
3950pub async fn get_by_ids_and_visibility(
3951    conn: &mut PgConnection,
3952    ids: &[Uuid],
3953    page_visibility: PageVisibility,
3954) -> ModelResult<Vec<Page>> {
3955    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
3956    let pages = sqlx::query_as!(
3957        Page,
3958        "
3959SELECT id,
3960    created_at,
3961    updated_at,
3962    course_id,
3963    exam_id,
3964    chapter_id,
3965    url_path,
3966    title,
3967    deleted_at,
3968    content,
3969    order_number,
3970    copied_from,
3971    hidden,
3972    page_language_group_id
3973FROM pages
3974WHERE id = ANY($1)
3975    AND hidden IS DISTINCT FROM $2
3976    AND deleted_at IS NULL
3977    ",
3978        ids,
3979        inverse_visibility_filter
3980    )
3981    .fetch_all(conn)
3982    .await?;
3983    Ok(pages)
3984}
3985
3986pub async fn get_by_ids_deleted_and_visibility(
3987    conn: &mut PgConnection,
3988    ids: &[Uuid],
3989    page_visibility: PageVisibility,
3990) -> ModelResult<Vec<Page>> {
3991    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
3992    let pages = sqlx::query_as!(
3993        Page,
3994        "
3995SELECT id,
3996    created_at,
3997    updated_at,
3998    course_id,
3999    exam_id,
4000    chapter_id,
4001    url_path,
4002    title,
4003    deleted_at,
4004    content,
4005    order_number,
4006    copied_from,
4007    hidden,
4008    page_language_group_id
4009FROM pages
4010WHERE id = ANY($1)
4011    AND hidden IS DISTINCT FROM $2
4012    AND deleted_at IS NOT NULL
4013    ",
4014        ids,
4015        inverse_visibility_filter
4016    )
4017    .fetch_all(conn)
4018    .await?;
4019    Ok(pages)
4020}
4021
4022#[cfg(test)]
4023mod test {
4024    use chrono::TimeZone;
4025
4026    use super::*;
4027    use crate::{exams::NewExam, test_helper::*};
4028
4029    #[test]
4030    fn normalizes_decoded_page_paths_for_storage_and_lookup() {
4031        assert_eq!(
4032            normalize_url_path_for_storage("/foo bar#part"),
4033            "/foo%20bar%23part"
4034        );
4035        assert_eq!(
4036            normalize_url_path_for_storage("/foo%20bar%23part"),
4037            "/foo%20bar%23part"
4038        );
4039        assert_eq!(
4040            normalize_url_path_for_storage(" /literal%percent "),
4041            "/literal%25percent"
4042        );
4043        assert_eq!(
4044            normalize_url_path_for_storage(" /literal%25percent "),
4045            "/literal%25percent"
4046        );
4047    }
4048
4049    #[tokio::test]
4050    async fn gets_organization_id() {
4051        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, :page);
4052
4053        let course_page_org = get_organization_id(tx.as_mut(), page).await.unwrap();
4054        assert_eq!(org, course_page_org);
4055
4056        let new_exam_id = crate::exams::insert(
4057            tx.as_mut(),
4058            PKeyPolicy::Generate,
4059            &NewExam {
4060                name: "name".to_string(),
4061                starts_at: None,
4062                ends_at: None,
4063                time_minutes: 120,
4064                organization_id: org,
4065                minimum_points_treshold: 24,
4066                grade_manually: false,
4067            },
4068        )
4069        .await
4070        .unwrap();
4071        let page = crate::pages::insert_page(
4072            tx.as_mut(),
4073            NewPage {
4074                exercises: vec![],
4075                exercise_slides: vec![],
4076                exercise_tasks: vec![],
4077                content: vec![],
4078                url_path: "url".to_string(),
4079                title: "title".to_string(),
4080                course_id: None,
4081                exam_id: Some(new_exam_id),
4082                chapter_id: None,
4083                front_page_of_chapter_id: None,
4084                content_search_language: None,
4085            },
4086            user,
4087            |_, _, _| unimplemented!(),
4088            |_| unimplemented!(),
4089        )
4090        .await
4091        .unwrap();
4092        let exam_page_org = get_organization_id(tx.as_mut(), page.id).await.unwrap();
4093        assert_eq!(org, exam_page_org);
4094    }
4095
4096    #[tokio::test]
4097    async fn page_update_validation_works() {
4098        let e1 = CmsPageExercise {
4099            id: Uuid::parse_str("0c9dca80-5904-4d35-a945-8c080446f667").unwrap(),
4100            name: "".to_string(),
4101            order_number: 1,
4102            score_maximum: 1,
4103            max_tries_per_slide: None,
4104            limit_number_of_tries: false,
4105            deadline: Some(Utc.with_ymd_and_hms(2125, 1, 1, 23, 59, 59).unwrap()),
4106            needs_peer_review: false,
4107            needs_self_review: false,
4108            peer_or_self_review_config: None,
4109            peer_or_self_review_questions: None,
4110            use_course_default_peer_or_self_review_config: false,
4111            teacher_reviews_answer_after_locking: true,
4112        };
4113        let e1_s1 = CmsPageExerciseSlide {
4114            id: Uuid::parse_str("43380e81-6ff2-4f46-9f38-af0ac6a8421a").unwrap(),
4115            exercise_id: e1.id,
4116            order_number: 1,
4117        };
4118        let e1_s1_t1 = CmsPageExerciseTask {
4119            id: Uuid::parse_str("6fb19c22-bca0-42cf-8be5-4141e21cc7a9").unwrap(),
4120            exercise_slide_id: e1_s1.id,
4121            assignment: serde_json::json!([]),
4122            exercise_type: "exercise".to_string(),
4123            private_spec: None,
4124            order_number: 1,
4125        };
4126
4127        // Works without exercises
4128        assert!(
4129            create_update(vec![], vec![], vec![])
4130                .validate_exercise_data()
4131                .is_ok()
4132        );
4133
4134        // Works with single valid exercise
4135        assert!(
4136            create_update(
4137                vec![e1.clone()],
4138                vec![e1_s1.clone()],
4139                vec![e1_s1_t1.clone()],
4140            )
4141            .validate_exercise_data()
4142            .is_ok()
4143        );
4144
4145        // Fails with missing slide
4146        assert!(
4147            create_update(vec![e1.clone()], vec![], vec![e1_s1_t1],)
4148                .validate_exercise_data()
4149                .is_err()
4150        );
4151
4152        // Fails with missing task
4153        assert!(
4154            create_update(vec![e1], vec![e1_s1], vec![],)
4155                .validate_exercise_data()
4156                .is_err()
4157        );
4158    }
4159
4160    fn create_update(
4161        exercises: Vec<CmsPageExercise>,
4162        exercise_slides: Vec<CmsPageExerciseSlide>,
4163        exercise_tasks: Vec<CmsPageExerciseTask>,
4164    ) -> CmsPageUpdate {
4165        CmsPageUpdate {
4166            content: vec![],
4167            exercises,
4168            exercise_slides,
4169            exercise_tasks,
4170            url_path: "".to_string(),
4171            title: "".to_string(),
4172            chapter_id: None,
4173        }
4174    }
4175
4176    fn lock_chapter_test_block(hidden_text: &str) -> GutenbergBlock {
4177        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
4178            "moocfi/lock-chapter",
4179            headless_lms_utils::attributes! {},
4180            vec![GutenbergBlock::paragraph(hidden_text)],
4181        )
4182    }
4183
4184    fn first_lock_chapter_inner_block_count(page: &Page) -> usize {
4185        page.blocks_cloned().unwrap()[0].inner_blocks.len()
4186    }
4187
4188    /// Updates chapter locking on a course for test setup.
4189    async fn set_course_chapter_locking_enabled(
4190        tx: &mut PgConnection,
4191        course_id: Uuid,
4192        chapter_locking_enabled: bool,
4193    ) {
4194        let course_before_update = courses::get_course(tx, course_id).await.unwrap();
4195        courses::update_course(
4196            tx,
4197            course_id,
4198            courses::CourseUpdate {
4199                chapter_locking_enabled,
4200                name: course_before_update.name,
4201                description: course_before_update.description,
4202                is_draft: course_before_update.is_draft,
4203                is_test_mode: course_before_update.is_test_mode,
4204                can_add_chatbot: course_before_update.can_add_chatbot,
4205                is_unlisted: course_before_update.is_unlisted,
4206                is_joinable_by_code_only: course_before_update.is_joinable_by_code_only,
4207                ask_marketing_consent: course_before_update.ask_marketing_consent,
4208                flagged_answers_threshold: course_before_update
4209                    .flagged_answers_threshold
4210                    .unwrap_or_default(),
4211                flagged_answers_skip_manual_review_and_allow_retry: course_before_update
4212                    .flagged_answers_skip_manual_review_and_allow_retry,
4213                closed_at: course_before_update.closed_at,
4214                closed_additional_message: course_before_update.closed_additional_message,
4215                closed_course_successor_id: course_before_update.closed_course_successor_id,
4216            },
4217        )
4218        .await
4219        .unwrap();
4220    }
4221
4222    #[test]
4223    fn sanitized_searchable_text_for_public_page_hides_lock_chapter_inner_blocks() {
4224        let content = serde_json::to_value(vec![
4225            GutenbergBlock::paragraph("Visible introduction"),
4226            lock_chapter_test_block("Model solution"),
4227        ])
4228        .unwrap();
4229
4230        let searchable_text = sanitized_searchable_text_for_public_page(&content);
4231
4232        assert!(searchable_text.contains("Visible introduction"));
4233        assert!(!searchable_text.contains("Model solution"));
4234    }
4235
4236    #[tokio::test]
4237    async fn filter_course_material_pages_hides_lock_chapter_inner_blocks_until_reviews_complete() {
4238        insert_data!(
4239            :tx,
4240            :user,
4241            :org,
4242            :course,
4243            instance: _instance,
4244            :course_module,
4245            chapter: chapter_id,
4246            page: page_id,
4247            exercise: exercise_id,
4248            slide: _exercise_slide_id,
4249            task: _exercise_task_id
4250        );
4251        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
4252
4253        update_page_content(
4254            tx.as_mut(),
4255            page_id,
4256            &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
4257        )
4258        .await
4259        .unwrap();
4260
4261        crate::exercises::update_teacher_reviews_answer_after_locking(
4262            tx.as_mut(),
4263            exercise_id,
4264            true,
4265        )
4266        .await
4267        .unwrap();
4268        crate::user_exercise_states::get_or_create_user_exercise_state(
4269            tx.as_mut(),
4270            user,
4271            exercise_id,
4272            Some(course),
4273            None,
4274        )
4275        .await
4276        .unwrap();
4277        crate::user_chapter_locking_statuses::complete_and_lock_chapter(
4278            tx.as_mut(),
4279            user,
4280            chapter_id,
4281            course,
4282        )
4283        .await
4284        .unwrap();
4285        crate::user_exercise_states::update_reviewing_stage(
4286            tx.as_mut(),
4287            user,
4288            CourseOrExamId::Course(course),
4289            exercise_id,
4290            crate::user_exercise_states::ReviewingStage::WaitingForManualGrading,
4291        )
4292        .await
4293        .unwrap();
4294
4295        let page = get_page(tx.as_mut(), page_id).await.unwrap();
4296        let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
4297            .await
4298            .unwrap();
4299        assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 0);
4300
4301        crate::user_exercise_states::update_reviewing_stage(
4302            tx.as_mut(),
4303            user,
4304            CourseOrExamId::Course(course),
4305            exercise_id,
4306            crate::user_exercise_states::ReviewingStage::ReviewedAndLocked,
4307        )
4308        .await
4309        .unwrap();
4310
4311        let page = get_page(tx.as_mut(), page_id).await.unwrap();
4312        let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
4313            .await
4314            .unwrap();
4315        assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 1);
4316    }
4317
4318    #[tokio::test]
4319    async fn filter_course_material_pages_with_exercises_hides_lock_chapter_inner_blocks() {
4320        insert_data!(
4321            :tx,
4322            :user,
4323            :org,
4324            :course,
4325            instance: _instance,
4326            :course_module,
4327            chapter: _chapter_id,
4328            page: page_id
4329        );
4330
4331        update_page_content(
4332            tx.as_mut(),
4333            page_id,
4334            &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
4335        )
4336        .await
4337        .unwrap();
4338
4339        let page = get_page(tx.as_mut(), page_id).await.unwrap();
4340        let filtered_pages = filter_course_material_pages_with_exercises(
4341            tx.as_mut(),
4342            None,
4343            vec![PageWithExercises {
4344                page,
4345                exercises: vec![],
4346            }],
4347        )
4348        .await
4349        .unwrap();
4350
4351        assert_eq!(
4352            first_lock_chapter_inner_block_count(&filtered_pages[0].page),
4353            0
4354        );
4355    }
4356
4357    #[tokio::test]
4358    async fn filter_course_material_pages_does_not_initialize_lock_status_when_course_locking_is_disabled()
4359     {
4360        insert_data!(
4361            :tx,
4362            :user,
4363            :org,
4364            :course,
4365            instance: _instance,
4366            :course_module,
4367            chapter: chapter_id,
4368            page: page_id
4369        );
4370
4371        set_course_chapter_locking_enabled(tx.as_mut(), course, false).await;
4372
4373        update_page_content(
4374            tx.as_mut(),
4375            page_id,
4376            &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
4377        )
4378        .await
4379        .unwrap();
4380
4381        let status = user_chapter_locking_statuses::get_or_init_status(
4382            tx.as_mut(),
4383            user,
4384            chapter_id,
4385            Some(course),
4386            Some(false),
4387        )
4388        .await
4389        .unwrap();
4390
4391        let page = get_page(tx.as_mut(), page_id).await.unwrap();
4392        let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
4393            .await
4394            .unwrap();
4395
4396        assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 0);
4397
4398        assert!(status.is_none());
4399    }
4400
4401    #[tokio::test]
4402    async fn page_search_results_do_not_leak_hidden_lock_chapter_snippet_text() {
4403        insert_data!(
4404            :tx,
4405            :user,
4406            :org,
4407            :course,
4408            instance: _instance,
4409            :course_module,
4410            chapter: _chapter_id,
4411            page: page_id
4412        );
4413
4414        update_page_content(
4415            tx.as_mut(),
4416            page_id,
4417            &serde_json::to_value(vec![
4418                GutenbergBlock::paragraph("Visible introduction"),
4419                lock_chapter_test_block("Model solution"),
4420            ])
4421            .unwrap(),
4422        )
4423        .await
4424        .unwrap();
4425
4426        let results = get_page_search_results_for_words(
4427            tx.as_mut(),
4428            course,
4429            &SearchRequest {
4430                query: "Visible".to_string(),
4431            },
4432        )
4433        .await
4434        .unwrap();
4435
4436        let result = results
4437            .iter()
4438            .find(|result| result.id == page_id)
4439            .expect("page should match visible content query");
4440        let content_headline = result.content_headline.as_deref().unwrap_or_default();
4441
4442        assert!(!content_headline.contains("Model solution"));
4443    }
4444
4445    #[tokio::test]
4446    async fn page_upsert_peer_reviews_work() {
4447        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
4448        let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
4449        let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
4450        let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
4451            .await
4452            .unwrap();
4453
4454        let pr1 = CmsPeerOrSelfReviewConfig {
4455            id:pr_id,
4456            exercise_id: Some(exercise_id),
4457            course_id: course,
4458            processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
4459            accepting_threshold: 0.5,
4460            peer_reviews_to_give: 2,
4461            peer_reviews_to_receive: 1,
4462            points_are_all_or_nothing: false,
4463            reset_answer_if_zero_points_from_review: false,
4464            review_instructions: None,
4465        };
4466        let prq = CmsPeerOrSelfReviewQuestion {
4467            id: prq_id,
4468            peer_or_self_review_config_id: pr_id,
4469            answer_required: true,
4470            order_number: 0,
4471            question: "juu".to_string(),
4472            question_type:
4473                crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
4474            weight: 0.31,
4475        };
4476        let mut remapped_exercises = HashMap::new();
4477        remapped_exercises.insert(exercise_id, exercise);
4478        let pr_res = upsert_peer_or_self_review_configs(
4479            tx.as_mut(),
4480            &[],
4481            &[pr1],
4482            &remapped_exercises,
4483            false,
4484        )
4485        .await
4486        .unwrap();
4487        let prq_res =
4488            upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, false)
4489                .await
4490                .unwrap();
4491
4492        assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
4493
4494        assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
4495        assert!(!pr_res.get(&pr_id).unwrap().points_are_all_or_nothing);
4496        assert_eq!(prq_res.get(&prq_id).unwrap().weight, 0.31);
4497    }
4498
4499    #[tokio::test]
4500    async fn page_upsert_peer_reviews_work_retain_ids() {
4501        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
4502        let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
4503            .await
4504            .unwrap();
4505        let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
4506        let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
4507        let pr1 = CmsPeerOrSelfReviewConfig {
4508            id:pr_id,
4509            exercise_id: Some(exercise_id),
4510            course_id: course,
4511            processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
4512            accepting_threshold: 0.5,
4513            peer_reviews_to_give: 2,
4514            peer_reviews_to_receive: 1,
4515            points_are_all_or_nothing: true,
4516            reset_answer_if_zero_points_from_review: false,
4517            review_instructions: None,
4518        };
4519        let prq = CmsPeerOrSelfReviewQuestion {
4520            id: prq_id,
4521            peer_or_self_review_config_id: pr_id,
4522            answer_required: true,
4523            order_number: 0,
4524            question: "juu".to_string(),
4525            question_type:
4526                crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
4527            weight: 0.0,
4528        };
4529        let mut remapped_exercises = HashMap::new();
4530        remapped_exercises.insert(exercise_id, exercise);
4531        let pr_res =
4532            upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[pr1], &remapped_exercises, true)
4533                .await
4534                .unwrap();
4535        let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, true)
4536            .await
4537            .unwrap();
4538
4539        assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
4540        assert!(pr_res.get(&pr_id).unwrap().id == pr_id);
4541
4542        assert!(prq_res.get(&prq_id).unwrap().id == prq_id);
4543        assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
4544    }
4545
4546    #[tokio::test]
4547    async fn page_upsert_peer_reviews_work_empty() {
4548        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
4549        let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
4550            .await
4551            .unwrap();
4552        let mut remapped_exercises = HashMap::new();
4553        remapped_exercises.insert(exercise_id, exercise);
4554        let pr_res =
4555            upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[], &remapped_exercises, true)
4556                .await
4557                .unwrap();
4558        let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[], &pr_res, true)
4559            .await
4560            .unwrap();
4561
4562        assert!(pr_res.is_empty());
4563        assert!(prq_res.is_empty());
4564    }
4565
4566    #[tokio::test]
4567    async fn reorder_top_level_pages_works() {
4568        insert_data!(:tx, :user, :org, :course);
4569
4570        // First, delete any existing pages in this course to ensure a clean slate
4571        let existing_pages =
4572            get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
4573                .await
4574                .unwrap();
4575        for page in &existing_pages {
4576            delete_page_and_exercises(tx.as_mut(), page.id, user)
4577                .await
4578                .unwrap();
4579        }
4580
4581        // Create our test pages
4582        let page1 = NewCoursePage::new(course, 0, "top-page-1", "Top Page 1");
4583        let (page1_id, _) = insert_course_page(tx.as_mut(), &page1, user).await.unwrap();
4584        let page2 = NewCoursePage::new(course, 1, "top-page-2", "Top Page 2");
4585        let (page2_id, _) = insert_course_page(tx.as_mut(), &page2, user).await.unwrap();
4586        let page3 = NewCoursePage::new(course, 2, "top-page-3", "Top Page 3");
4587        let (page3_id, _) = insert_course_page(tx.as_mut(), &page3, user).await.unwrap();
4588
4589        let mut pages =
4590            get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
4591                .await
4592                .unwrap();
4593
4594        let page1_index = pages.iter().position(|p| p.id == page1_id).unwrap();
4595        let page2_index = pages.iter().position(|p| p.id == page2_id).unwrap();
4596        let page3_index = pages.iter().position(|p| p.id == page3_id).unwrap();
4597
4598        pages[page1_index].order_number = 2;
4599        pages[page3_index].order_number = 1;
4600        pages[page2_index].order_number = 3;
4601
4602        // Apply the reordering
4603        reorder_pages(tx.as_mut(), &pages, course).await.unwrap();
4604
4605        // Check that the reordering took effect
4606        let page1_updated = get_page(tx.as_mut(), page1_id).await.unwrap();
4607        let page2_updated = get_page(tx.as_mut(), page2_id).await.unwrap();
4608        let page3_updated = get_page(tx.as_mut(), page3_id).await.unwrap();
4609
4610        assert_eq!(page1_updated.order_number, 2);
4611        assert_eq!(page2_updated.order_number, 3);
4612        assert_eq!(page3_updated.order_number, 1);
4613    }
4614}