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