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}
1003
1004impl CmsPageExercise {
1005    fn from_exercise_and_peer_review_data(
1006        exercise: Exercise,
1007        peer_or_self_review_config: Option<CmsPeerOrSelfReviewConfig>,
1008        peer_or_self_review_questions: Option<Vec<CmsPeerOrSelfReviewQuestion>>,
1009    ) -> Self {
1010        Self {
1011            id: exercise.id,
1012            name: exercise.name,
1013            order_number: exercise.order_number,
1014            score_maximum: exercise.score_maximum,
1015            max_tries_per_slide: exercise.max_tries_per_slide,
1016            limit_number_of_tries: exercise.limit_number_of_tries,
1017            deadline: exercise.deadline,
1018            needs_peer_review: exercise.needs_peer_review,
1019            needs_self_review: exercise.needs_self_review,
1020            use_course_default_peer_or_self_review_config: exercise
1021                .use_course_default_peer_or_self_review_config,
1022            peer_or_self_review_config,
1023            peer_or_self_review_questions,
1024        }
1025    }
1026}
1027
1028#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
1029#[cfg_attr(feature = "ts_rs", derive(TS))]
1030pub struct CmsPageExerciseSlide {
1031    pub id: Uuid,
1032    pub exercise_id: Uuid,
1033    pub order_number: i32,
1034}
1035
1036impl From<ExerciseSlide> for CmsPageExerciseSlide {
1037    fn from(slide: ExerciseSlide) -> Self {
1038        Self {
1039            id: slide.id,
1040            exercise_id: slide.exercise_id,
1041            order_number: slide.order_number,
1042        }
1043    }
1044}
1045
1046#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
1047#[cfg_attr(feature = "ts_rs", derive(TS))]
1048pub struct CmsPageExerciseTask {
1049    pub id: Uuid,
1050    pub exercise_slide_id: Uuid,
1051    pub assignment: serde_json::Value,
1052    pub exercise_type: String,
1053    pub private_spec: Option<serde_json::Value>,
1054    pub order_number: i32,
1055}
1056
1057impl From<ExerciseTask> for CmsPageExerciseTask {
1058    fn from(task: ExerciseTask) -> Self {
1059        CmsPageExerciseTask {
1060            id: task.id,
1061            exercise_slide_id: task.exercise_slide_id,
1062            assignment: task.assignment,
1063            exercise_type: task.exercise_type,
1064            private_spec: task.private_spec,
1065            order_number: task.order_number,
1066        }
1067    }
1068}
1069
1070#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
1071#[cfg_attr(feature = "ts_rs", derive(TS))]
1072pub struct CmsPageUpdate {
1073    pub content: Vec<GutenbergBlock>,
1074    pub exercises: Vec<CmsPageExercise>,
1075    pub exercise_slides: Vec<CmsPageExerciseSlide>,
1076    pub exercise_tasks: Vec<CmsPageExerciseTask>,
1077    pub url_path: String,
1078    pub title: String,
1079    pub chapter_id: Option<Uuid>,
1080}
1081
1082impl CmsPageUpdate {
1083    /// Checks that each exercise has at least one slide and each slide has at least one task.
1084    pub fn validate_exercise_data(&self) -> ModelResult<()> {
1085        let mut exercise_ids: HashMap<Uuid, bool> =
1086            self.exercises.iter().map(|x| (x.id, false)).collect();
1087        let mut slide_ids = self
1088            .exercise_slides
1089            .iter()
1090            .map(|x| {
1091                if let hash_map::Entry::Occupied(mut e) = exercise_ids.entry(x.exercise_id) {
1092                    e.insert(true);
1093                    Ok((x.id, false))
1094                } else {
1095                    Err(ModelError::new(
1096                        ModelErrorType::PreconditionFailed,
1097                        "Exercide ids in slides don't match.".to_string(),
1098                        None,
1099                    ))
1100                }
1101            })
1102            .collect::<ModelResult<HashMap<Uuid, bool>>>()?;
1103
1104        if let Some((exercise_id, _)) = exercise_ids.into_iter().find(|(_, x)| !x) {
1105            return Err(ModelError::new(
1106                ModelErrorType::PreconditionFailedWithCMSAnchorBlockId {
1107                    id: exercise_id,
1108                    description: "Exercise must have at least one slide.",
1109                },
1110                "Exercise must have at least one slide.".to_string(),
1111                None,
1112            ));
1113        }
1114
1115        for task in self.exercise_tasks.iter() {
1116            if let hash_map::Entry::Occupied(mut e) = slide_ids.entry(task.exercise_slide_id) {
1117                e.insert(true);
1118            } else {
1119                return Err(ModelError::new(
1120                    ModelErrorType::PreconditionFailed,
1121                    "Exercise slide ids in tasks don't match.".to_string(),
1122                    None,
1123                ));
1124            }
1125        }
1126        if let Some((slide_id, _)) = slide_ids.into_iter().find(|(_, x)| !x) {
1127            return Err(ModelError::new(
1128                ModelErrorType::PreconditionFailedWithCMSAnchorBlockId {
1129                    id: slide_id,
1130                    description: "Exercise slide must have at least one task.",
1131                },
1132                "Exercise slide must have at least one task.".to_string(),
1133                None,
1134            ));
1135        }
1136        Ok(())
1137    }
1138}
1139
1140#[derive(Debug, Clone)]
1141pub struct PageUpdateArgs {
1142    pub page_id: Uuid,
1143    pub author: Uuid,
1144    pub cms_page_update: CmsPageUpdate,
1145    pub retain_ids: bool,
1146    pub history_change_reason: HistoryChangeReason,
1147    pub is_exam_page: bool,
1148}
1149
1150pub async fn update_page(
1151    conn: &mut PgConnection,
1152    page_update: PageUpdateArgs,
1153    spec_fetcher: impl SpecFetcher,
1154    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
1155) -> ModelResult<ContentManagementPage> {
1156    let mut cms_page_update = page_update.cms_page_update;
1157    cms_page_update.validate_exercise_data()?;
1158
1159    for exercise in cms_page_update.exercises.iter_mut() {
1160        if let Some(peer_or_self_review_questions) = exercise.peer_or_self_review_questions.as_mut()
1161        {
1162            normalize_cms_peer_or_self_review_questions(peer_or_self_review_questions);
1163        }
1164    }
1165
1166    let content = replace_duplicate_client_ids(cms_page_update.content.clone());
1167
1168    if !page_update.is_exam_page
1169        && cms_page_update.chapter_id.is_none()
1170        && contains_blocks_not_allowed_in_top_level_pages(&content)
1171    {
1172        return Err(ModelError::new(
1173               ModelErrorType::Generic , "Top level non-exam pages cannot contain exercises, exercise tasks or list of exercises in the chapter".to_string(), None
1174            ));
1175    }
1176
1177    let mut tx = conn.begin().await?;
1178
1179    // Updating page
1180    let page = sqlx::query_as!(
1181        Page,
1182        r"
1183UPDATE pages
1184SET content = $2,
1185  url_path = $3,
1186  title = $4,
1187  chapter_id = $5
1188WHERE id = $1
1189RETURNING id,
1190  created_at,
1191  updated_at,
1192  course_id,
1193  exam_id,
1194  chapter_id,
1195  url_path,
1196  title,
1197  deleted_at,
1198  content,
1199  order_number,
1200  copied_from,
1201  pages.hidden,
1202  pages.page_language_group_id
1203        ",
1204        page_update.page_id,
1205        serde_json::to_value(&content)?,
1206        cms_page_update.url_path.trim(),
1207        cms_page_update.title.trim(),
1208        cms_page_update.chapter_id
1209    )
1210    .fetch_one(&mut *tx)
1211    .await?;
1212
1213    // Exercises
1214    let existing_exercise_ids =
1215        crate::exercises::delete_exercises_by_page_id(&mut tx, page.id).await?;
1216    let remapped_exercises = upsert_exercises(
1217        &mut tx,
1218        &page,
1219        &existing_exercise_ids,
1220        &cms_page_update.exercises,
1221        page_update.retain_ids,
1222    )
1223    .await?;
1224
1225    // Exercise slides
1226    let existing_exercise_slide_ids =
1227        crate::exercise_slides::delete_exercise_slides_by_exercise_ids(
1228            &mut tx,
1229            &existing_exercise_ids,
1230        )
1231        .await?;
1232    let remapped_exercise_slides = upsert_exercise_slides(
1233        &mut tx,
1234        &remapped_exercises,
1235        &existing_exercise_slide_ids,
1236        &cms_page_update.exercise_slides,
1237        page_update.retain_ids,
1238    )
1239    .await?;
1240
1241    // Peer reviews
1242    let existing_peer_or_self_review_config_ids =
1243        crate::peer_or_self_review_configs::delete_peer_reviews_by_exrcise_ids(
1244            &mut tx,
1245            &existing_exercise_ids,
1246        )
1247        .await?;
1248
1249    let (peer_or_self_review_configs, peer_or_self_review_questions) = cms_page_update
1250        .exercises
1251        .into_iter()
1252        .filter(|e| !e.use_course_default_peer_or_self_review_config)
1253        .flat_map(|e| {
1254            e.peer_or_self_review_config
1255                .zip(e.peer_or_self_review_questions)
1256        })
1257        .fold((vec![], vec![]), |(mut a, mut b), (pr, prq)| {
1258            a.push(pr);
1259            b.extend(prq);
1260            (a, b)
1261        });
1262
1263    let remapped_peer_or_self_review_configs = upsert_peer_or_self_review_configs(
1264        &mut tx,
1265        &existing_peer_or_self_review_config_ids,
1266        &peer_or_self_review_configs,
1267        &remapped_exercises,
1268        page_update.retain_ids,
1269    )
1270    .await?;
1271
1272    // Peer review questions
1273    let existing_peer_or_self_review_questions =
1274        crate::peer_or_self_review_questions::delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
1275            &mut tx,
1276            &existing_peer_or_self_review_config_ids,
1277        )
1278        .await?;
1279
1280    let remapped_peer_or_self_review_questions = upsert_peer_or_self_review_questions(
1281        &mut tx,
1282        &existing_peer_or_self_review_questions,
1283        &peer_or_self_review_questions,
1284        &remapped_peer_or_self_review_configs,
1285        page_update.retain_ids,
1286    )
1287    .await?;
1288
1289    // Set as deleted and get existing specs
1290    let existing_exercise_task_specs = sqlx::query_as!(
1291        ExerciseTaskIdAndSpec,
1292        "
1293UPDATE exercise_tasks
1294SET deleted_at = now()
1295WHERE exercise_slide_id = ANY($1)
1296AND deleted_at IS NULL
1297RETURNING id,
1298  private_spec,
1299  public_spec,
1300  model_solution_spec;
1301        ",
1302        &existing_exercise_slide_ids,
1303    )
1304    .fetch_all(&mut *tx)
1305    .await?;
1306    let final_tasks = upsert_exercise_tasks(
1307        &mut tx,
1308        &remapped_exercise_slides,
1309        &existing_exercise_task_specs,
1310        &cms_page_update.exercise_tasks,
1311        page_update.retain_ids,
1312        &spec_fetcher,
1313        fetch_service_info,
1314    )
1315    .await?;
1316
1317    // Now, we might have changed some of the exercise ids and need to do the same changes in the page content as well
1318    let new_content = headless_lms_utils::document_schema_processor::remap_ids_in_content(
1319        &page.content,
1320        remapped_exercises
1321            .iter()
1322            .map(|(id, e)| (*id, e.id))
1323            .collect::<HashMap<Uuid, Uuid>>(),
1324    )?;
1325
1326    let page = sqlx::query_as!(
1327        Page,
1328        "
1329UPDATE pages
1330SET content = $1
1331WHERE id = $2
1332RETURNING id,
1333  created_at,
1334  updated_at,
1335  course_id,
1336  exam_id,
1337  chapter_id,
1338  url_path,
1339  title,
1340  deleted_at,
1341  content,
1342  order_number,
1343  copied_from,
1344  hidden,
1345  page_language_group_id
1346        ",
1347        new_content,
1348        page.id
1349    )
1350    .fetch_one(&mut *tx)
1351    .await?;
1352
1353    let x = remapped_exercises.into_values().collect::<Vec<_>>();
1354    let final_exercises = x
1355        .iter()
1356        .map(|e| {
1357            let peer_or_self_review_config = remapped_peer_or_self_review_configs
1358                .values()
1359                .find(|prc| prc.exercise_id == Some(e.id));
1360            if let Some(prc) = peer_or_self_review_config {
1361                let peer_or_self_review_questions = remapped_peer_or_self_review_questions
1362                    .values()
1363                    .filter(|prq| prq.peer_or_self_review_config_id == prc.id)
1364                    .cloned()
1365                    .collect::<Vec<_>>();
1366                return CmsPageExercise::from_exercise_and_peer_review_data(
1367                    e.clone(),
1368                    Some(prc.clone()),
1369                    Some(peer_or_self_review_questions),
1370                );
1371            }
1372            CmsPageExercise::from_exercise_and_peer_review_data(e.clone(), None, None)
1373        })
1374        .collect();
1375    let final_slides: Vec<CmsPageExerciseSlide> = remapped_exercise_slides.into_values().collect();
1376    let final_peer_reviews: Vec<CmsPeerOrSelfReviewConfig> =
1377        remapped_peer_or_self_review_configs.into_values().collect();
1378    let final_peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion> =
1379        remapped_peer_or_self_review_questions
1380            .into_values()
1381            .collect();
1382    let history_content = PageHistoryContent {
1383        content: page.content.clone(),
1384        exercises: final_exercises,
1385        exercise_slides: final_slides,
1386        exercise_tasks: final_tasks,
1387        peer_or_self_review_configs: final_peer_reviews,
1388        peer_or_self_review_questions: final_peer_or_self_review_questions,
1389    };
1390    crate::page_history::insert(
1391        &mut tx,
1392        PKeyPolicy::Generate,
1393        page_update.page_id,
1394        &cms_page_update.title,
1395        &history_content,
1396        page_update.history_change_reason,
1397        page_update.author,
1398        None,
1399    )
1400    .await?;
1401    let organization_id = get_organization_id(&mut tx, page.id).await?;
1402
1403    tx.commit().await?;
1404
1405    Ok(ContentManagementPage {
1406        page,
1407        exercises: history_content.exercises,
1408        exercise_slides: history_content.exercise_slides,
1409        exercise_tasks: history_content.exercise_tasks,
1410        peer_or_self_review_configs: history_content.peer_or_self_review_configs,
1411        peer_or_self_review_questions: history_content.peer_or_self_review_questions,
1412        organization_id,
1413    })
1414}
1415
1416/// Remaps ids from updates to exercises that may have their ids regenerated.
1417async fn upsert_exercises(
1418    conn: &mut PgConnection,
1419    page: &Page,
1420    existing_exercise_ids: &[Uuid],
1421    exercise_updates: &[CmsPageExercise],
1422    retain_exercise_ids: bool,
1423) -> ModelResult<HashMap<Uuid, Exercise>> {
1424    let mut remapped_exercises = HashMap::new();
1425    for exercise_update in exercise_updates.iter() {
1426        let exercise_exists = existing_exercise_ids.contains(&exercise_update.id);
1427        let safe_for_db_exercise_id = if retain_exercise_ids || exercise_exists {
1428            exercise_update.id
1429        } else {
1430            Uuid::new_v4()
1431        };
1432
1433        // check if exercise exits
1434        let db_exercise = crate::exercises::get_by_id(&mut *conn, safe_for_db_exercise_id)
1435            .await
1436            .optional()?;
1437
1438        let mut exercise_language_group_id = None;
1439
1440        if let Some(db_exercise) = db_exercise {
1441            exercise_language_group_id = db_exercise.exercise_language_group_id;
1442        }
1443        if let Some(course_id) = page.course_id {
1444            let course = crate::courses::get_course(&mut *conn, course_id).await?;
1445
1446            exercise_language_group_id = Some(
1447                crate::exercise_language_groups::insert(
1448                    &mut *conn,
1449                    PKeyPolicy::Generate,
1450                    course.course_language_group_id,
1451                )
1452                .await?,
1453            );
1454        }
1455
1456        let exercise = sqlx::query_as!(
1457            Exercise,
1458            "
1459INSERT INTO exercises(
1460    id,
1461    course_id,
1462    name,
1463    order_number,
1464    page_id,
1465    chapter_id,
1466    exam_id,
1467    score_maximum,
1468    max_tries_per_slide,
1469    limit_number_of_tries,
1470    deadline,
1471    needs_peer_review,
1472    needs_self_review,
1473    use_course_default_peer_or_self_review_config,
1474    exercise_language_group_id
1475  )
1476VALUES (
1477    $1,
1478    $2,
1479    $3,
1480    $4,
1481    $5,
1482    $6,
1483    $7,
1484    $8,
1485    $9,
1486    $10,
1487    $11,
1488    $12,
1489    $13,
1490    $14,
1491    $15
1492  ) ON CONFLICT (id) DO
1493UPDATE
1494SET course_id = $2,
1495  name = $3,
1496  order_number = $4,
1497  page_id = $5,
1498  chapter_id = $6,
1499  exam_id = $7,
1500  score_maximum = $8,
1501  max_tries_per_slide = $9,
1502  limit_number_of_tries = $10,
1503  deadline = $11,
1504  needs_peer_review = $12,
1505  needs_self_review = $13,
1506  use_course_default_peer_or_self_review_config = $14,
1507  exercise_language_group_id = $15,
1508  deleted_at = NULL
1509RETURNING *;
1510            ",
1511            safe_for_db_exercise_id,
1512            page.course_id,
1513            exercise_update.name,
1514            exercise_update.order_number,
1515            page.id,
1516            page.chapter_id,
1517            page.exam_id,
1518            exercise_update.score_maximum,
1519            exercise_update.max_tries_per_slide,
1520            exercise_update.limit_number_of_tries,
1521            exercise_update.deadline,
1522            exercise_update.needs_peer_review,
1523            exercise_update.needs_self_review,
1524            exercise_update.use_course_default_peer_or_self_review_config,
1525            exercise_language_group_id,
1526        )
1527        .fetch_one(&mut *conn)
1528        .await?;
1529
1530        remapped_exercises.insert(exercise_update.id, exercise);
1531    }
1532    Ok(remapped_exercises)
1533}
1534
1535/// Remaps ids from updates to exercise slides that may have their ids changed.
1536async fn upsert_exercise_slides(
1537    conn: &mut PgConnection,
1538    remapped_exercises: &HashMap<Uuid, Exercise>,
1539    existing_slide_ids: &[Uuid],
1540    slide_updates: &[CmsPageExerciseSlide],
1541    retain_exercise_ids: bool,
1542) -> ModelResult<HashMap<Uuid, CmsPageExerciseSlide>> {
1543    let mut remapped_exercise_slides = HashMap::new();
1544    for slide_update in slide_updates.iter() {
1545        let slide_exists = existing_slide_ids.contains(&slide_update.id);
1546        let safe_for_db_slide_id = if retain_exercise_ids || slide_exists {
1547            slide_update.id
1548        } else {
1549            Uuid::new_v4()
1550        };
1551        let safe_for_db_exercise_id = remapped_exercises
1552            .get(&slide_update.exercise_id)
1553            .ok_or_else(|| {
1554                ModelError::new(
1555                    ModelErrorType::InvalidRequest,
1556                    "Illegal exercise id for exercise slide.".to_string(),
1557                    None,
1558                )
1559            })?
1560            .id;
1561
1562        let exercise_slide = sqlx::query_as!(
1563            CmsPageExerciseSlide,
1564            "
1565INSERT INTO exercise_slides (id, exercise_id, order_number)
1566VALUES ($1, $2, $3) ON CONFLICT (id) DO
1567UPDATE
1568SET exercise_id = $2,
1569  order_number = $3,
1570  deleted_at = NULL
1571RETURNING id,
1572  exercise_id,
1573  order_number;
1574            ",
1575            safe_for_db_slide_id,
1576            safe_for_db_exercise_id,
1577            slide_update.order_number,
1578        )
1579        .fetch_one(&mut *conn)
1580        .await?;
1581
1582        remapped_exercise_slides.insert(slide_update.id, exercise_slide);
1583    }
1584    Ok(remapped_exercise_slides)
1585}
1586
1587/// Remaps ids from updates to exercise tasks that may have their ids changed.
1588async fn upsert_exercise_tasks(
1589    conn: &mut PgConnection,
1590    remapped_slides: &HashMap<Uuid, CmsPageExerciseSlide>,
1591    existing_task_specs: &[ExerciseTaskIdAndSpec],
1592    task_updates: &[CmsPageExerciseTask],
1593    retain_exercise_ids: bool,
1594    spec_fetcher: impl SpecFetcher,
1595    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
1596) -> ModelResult<Vec<CmsPageExerciseTask>> {
1597    // For generating public specs for exercises.
1598    let exercise_types: Vec<String> = task_updates
1599        .iter()
1600        .map(|task| task.exercise_type.clone())
1601        .unique()
1602        .collect();
1603    let exercise_service_hashmap = exercise_service_info::get_selected_exercise_services_by_type(
1604        &mut *conn,
1605        &exercise_types,
1606        fetch_service_info,
1607    )
1608    .await?;
1609    let public_spec_urls_by_exercise_type = exercise_service_hashmap
1610        .iter()
1611        .map(|(key, (service, info))| Ok((key, get_internal_public_spec_url(service, info)?)))
1612        .collect::<ModelResult<HashMap<&String, Url>>>()?;
1613    let model_solution_urls_by_exercise_type = exercise_service_hashmap
1614        .iter()
1615        .map(|(key, (service, info))| Ok((key, get_model_solution_url(service, info)?)))
1616        .collect::<ModelResult<HashMap<&String, Url>>>()?;
1617
1618    let mut remapped_exercise_tasks = Vec::new();
1619    for task_update in task_updates.iter() {
1620        let existing_exercise_task = existing_task_specs.iter().find(|o| o.id == task_update.id);
1621        let safe_for_db_exercise_task_id = match existing_exercise_task {
1622            Some(_) => task_update.id,
1623            _ if retain_exercise_ids => task_update.id,
1624            None => Uuid::new_v4(),
1625        };
1626
1627        let task_exists = existing_task_specs
1628            .iter()
1629            .any(|task| task.id == task_update.id);
1630        let safe_for_db_task_id = if retain_exercise_ids || task_exists {
1631            task_update.id
1632        } else {
1633            Uuid::new_v4()
1634        };
1635        let normalized_task = NormalizedCmsExerciseTask {
1636            id: safe_for_db_task_id,
1637            assignment: task_update.assignment.clone(),
1638            exercise_type: task_update.exercise_type.clone(),
1639            private_spec: task_update.private_spec.clone(),
1640        };
1641        let model_solution_spec = fetch_derived_spec(
1642            existing_exercise_task,
1643            &normalized_task,
1644            &model_solution_urls_by_exercise_type,
1645            &spec_fetcher,
1646            existing_exercise_task.and_then(|value| value.model_solution_spec.clone()),
1647            task_update.id,
1648        )
1649        .await?;
1650        let public_spec: Option<serde_json::Value> = fetch_derived_spec(
1651            existing_exercise_task,
1652            &normalized_task,
1653            &public_spec_urls_by_exercise_type,
1654            &spec_fetcher,
1655            existing_exercise_task.and_then(|value| value.public_spec.clone()),
1656            task_update.id,
1657        )
1658        .await?;
1659        let safe_for_db_exercise_slide_id = remapped_slides
1660            .get(&task_update.exercise_slide_id)
1661            .ok_or_else(|| {
1662                ModelError::new(
1663                    ModelErrorType::InvalidRequest,
1664                    "Illegal exercise slide id for exercise task.".to_string(),
1665                    None,
1666                )
1667            })?
1668            .id;
1669
1670        // Upsert
1671        let exercise_task = sqlx::query_as!(
1672            CmsPageExerciseTask,
1673            "
1674INSERT INTO exercise_tasks(
1675    id,
1676    exercise_slide_id,
1677    exercise_type,
1678    assignment,
1679    public_spec,
1680    private_spec,
1681    model_solution_spec,
1682    order_number
1683  )
1684VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO
1685UPDATE
1686SET exercise_slide_id = $2,
1687  exercise_type = $3,
1688  assignment = $4,
1689  public_spec = $5,
1690  private_spec = $6,
1691  model_solution_spec = $7,
1692  order_number = $8,
1693  deleted_at = NULL
1694RETURNING id,
1695  exercise_slide_id,
1696  assignment,
1697  exercise_type,
1698  private_spec,
1699  order_number
1700                ",
1701            safe_for_db_exercise_task_id,
1702            safe_for_db_exercise_slide_id,
1703            task_update.exercise_type,
1704            task_update.assignment,
1705            public_spec,
1706            task_update.private_spec,
1707            model_solution_spec,
1708            task_update.order_number,
1709        )
1710        .fetch_one(&mut *conn)
1711        .await?;
1712        remapped_exercise_tasks.push(exercise_task)
1713    }
1714    Ok(remapped_exercise_tasks)
1715}
1716
1717pub async fn upsert_peer_or_self_review_configs(
1718    conn: &mut PgConnection,
1719    existing_peer_reviews: &[Uuid],
1720    peer_reviews: &[CmsPeerOrSelfReviewConfig],
1721    remapped_exercises: &HashMap<Uuid, Exercise>,
1722    retain_ids: bool,
1723) -> ModelResult<HashMap<Uuid, CmsPeerOrSelfReviewConfig>> {
1724    if peer_reviews.is_empty() {
1725        Ok(HashMap::new())
1726    } else {
1727        let mut new_peer_or_self_review_config_id_to_old_id = HashMap::new();
1728
1729        let mut sql: QueryBuilder<Postgres> = QueryBuilder::new(
1730            "INSERT INTO peer_or_self_review_configs (
1731        id,
1732        course_id,
1733        exercise_id,
1734        peer_reviews_to_give,
1735        peer_reviews_to_receive,
1736        processing_strategy,
1737        accepting_threshold,
1738        points_are_all_or_nothing,
1739        reset_answer_if_zero_points_from_review,
1740        review_instructions,
1741        deleted_at
1742      ) ",
1743        );
1744
1745        // No way to return from push_values, we can use this to detect an error after the push_values
1746        let mut illegal_exercise_id = None;
1747
1748        sql.push_values(peer_reviews.iter().take(1000), |mut x, pr| {
1749            let peer_review_exists = existing_peer_reviews.contains(&pr.id);
1750            let safe_for_db_peer_or_self_review_config_id = if retain_ids || peer_review_exists {
1751                pr.id
1752            } else {
1753                Uuid::new_v4()
1754            };
1755            new_peer_or_self_review_config_id_to_old_id
1756                .insert(safe_for_db_peer_or_self_review_config_id, pr.id);
1757
1758            let safe_for_db_exercise_id = pr.exercise_id.and_then(|id| {
1759                let res = remapped_exercises.get(&id).map(|e| e.id);
1760                if res.is_none() {
1761                    error!("Illegal exercise id {:?}", id);
1762                    illegal_exercise_id = Some(id);
1763                }
1764                res
1765            });
1766
1767            x.push_bind(safe_for_db_peer_or_self_review_config_id)
1768                .push_bind(pr.course_id)
1769                .push_bind(safe_for_db_exercise_id)
1770                .push_bind(pr.peer_reviews_to_give)
1771                .push_bind(pr.peer_reviews_to_receive)
1772                .push_bind(pr.processing_strategy)
1773                .push_bind(pr.accepting_threshold)
1774                .push_bind(pr.points_are_all_or_nothing)
1775                .push_bind(pr.reset_answer_if_zero_points_from_review)
1776                .push_bind(pr.review_instructions.clone())
1777                .push("NULL");
1778        });
1779
1780        if let Some(illegal_exercise_id) = illegal_exercise_id {
1781            return Err(ModelError::new(
1782                ModelErrorType::InvalidRequest,
1783                format!("Illegal exercise id {:?}", illegal_exercise_id),
1784                None,
1785            ));
1786        }
1787
1788        sql.push(
1789            " ON CONFLICT (id) DO
1790UPDATE
1791SET course_id = excluded.course_id,
1792  exercise_id = excluded.exercise_id,
1793  peer_reviews_to_give = excluded.peer_reviews_to_give,
1794  peer_reviews_to_receive = excluded.peer_reviews_to_receive,
1795  processing_strategy = excluded.processing_strategy,
1796  accepting_threshold = excluded.accepting_threshold,
1797  points_are_all_or_nothing = excluded.points_are_all_or_nothing,
1798  review_instructions = excluded.review_instructions,
1799  deleted_at = NULL
1800RETURNING id;
1801",
1802        );
1803
1804        let ids = sql
1805            .build()
1806            .fetch_all(&mut *conn)
1807            .await?
1808            .iter()
1809            .map(|x| x.get(0))
1810            .collect::<Vec<_>>();
1811
1812        let prs = sqlx::query_as!(
1813            CmsPeerOrSelfReviewConfig,
1814            r#"
1815SELECT id as "id!",
1816  course_id as "course_id!",
1817  exercise_id,
1818  peer_reviews_to_give as "peer_reviews_to_give!",
1819  peer_reviews_to_receive as "peer_reviews_to_receive!",
1820  processing_strategy AS "processing_strategy!: _",
1821  accepting_threshold "accepting_threshold!",
1822  points_are_all_or_nothing "points_are_all_or_nothing!",
1823  reset_answer_if_zero_points_from_review,
1824  review_instructions
1825FROM peer_or_self_review_configs
1826WHERE id IN (
1827    SELECT UNNEST($1::uuid [])
1828  )
1829  AND deleted_at IS NULL;
1830    "#,
1831            &ids
1832        )
1833        .fetch_all(&mut *conn)
1834        .await?;
1835
1836        let mut remapped_peer_reviews = HashMap::new();
1837
1838        for pr in prs {
1839            let old_id = new_peer_or_self_review_config_id_to_old_id
1840                .get(&pr.id)
1841                .ok_or_else(|| {
1842                    ModelError::new(
1843                        ModelErrorType::Generic,
1844                        "Inserted peer reviews not found".to_string(),
1845                        None,
1846                    )
1847                })?;
1848            remapped_peer_reviews.insert(*old_id, pr);
1849        }
1850
1851        Ok(remapped_peer_reviews)
1852    }
1853}
1854
1855pub async fn upsert_peer_or_self_review_questions(
1856    conn: &mut PgConnection,
1857    existing_peer_or_self_review_questions: &[Uuid],
1858    peer_or_self_review_questions: &[CmsPeerOrSelfReviewQuestion],
1859    remapped_peer_or_self_review_config_ids: &HashMap<Uuid, CmsPeerOrSelfReviewConfig>,
1860    retain_ids: bool,
1861) -> ModelResult<HashMap<Uuid, CmsPeerOrSelfReviewQuestion>> {
1862    if peer_or_self_review_questions.is_empty() {
1863        Ok(HashMap::new())
1864    } else {
1865        let mut new_peer_or_self_review_question_id_to_old_id = HashMap::new();
1866
1867        let mut sql: QueryBuilder<Postgres> = QueryBuilder::new(
1868            "INSERT INTO peer_or_self_review_questions (
1869        id,
1870        peer_or_self_review_config_id,
1871        order_number,
1872        question,
1873        question_type,
1874        answer_required,
1875        weight,
1876        deleted_at
1877      ) ",
1878        );
1879
1880        let peer_or_self_review_questions = peer_or_self_review_questions
1881            .iter()
1882            .take(1000)
1883            .map(|prq| {
1884                remapped_peer_or_self_review_config_ids
1885                    .get(&prq.peer_or_self_review_config_id)
1886                    .map(|r| (prq, r.id))
1887                    .ok_or_else(|| {
1888                        ModelError::new(
1889                            ModelErrorType::Generic,
1890                            "No peer review found for peer review questions".to_string(),
1891                            None,
1892                        )
1893                    })
1894            })
1895            .collect::<Result<Vec<_>, _>>()?;
1896
1897        sql.push_values(
1898            peer_or_self_review_questions,
1899            |mut x, (prq, peer_or_self_review_config_id)| {
1900                let peer_review_question_exists =
1901                    existing_peer_or_self_review_questions.contains(&prq.id);
1902                let safe_for_db_peer_or_self_review_question_id =
1903                    if retain_ids || peer_review_question_exists {
1904                        prq.id
1905                    } else {
1906                        Uuid::new_v4()
1907                    };
1908                new_peer_or_self_review_question_id_to_old_id
1909                    .insert(safe_for_db_peer_or_self_review_question_id, prq.id);
1910
1911                x.push_bind(safe_for_db_peer_or_self_review_question_id)
1912                    .push_bind(peer_or_self_review_config_id)
1913                    .push_bind(prq.order_number)
1914                    .push_bind(prq.question.as_str())
1915                    .push_bind(prq.question_type)
1916                    .push_bind(prq.answer_required)
1917                    .push_bind(prq.weight)
1918                    .push("NULL");
1919            },
1920        );
1921
1922        sql.push(
1923            " ON CONFLICT (id) DO
1924UPDATE
1925SET peer_or_self_review_config_id = excluded.peer_or_self_review_config_id,
1926    order_number = excluded.order_number,
1927    question = excluded.question,
1928    question_type = excluded.question_type,
1929    answer_required = excluded.answer_required,
1930    weight = excluded.weight,
1931    deleted_at = NULL
1932RETURNING id;
1933",
1934        );
1935
1936        let ids = sql
1937            .build()
1938            .fetch_all(&mut *conn)
1939            .await?
1940            .iter()
1941            .map(|x| x.get(0))
1942            .collect::<Vec<_>>();
1943
1944        let prqs = sqlx::query_as!(
1945            CmsPeerOrSelfReviewQuestion,
1946            r#"
1947SELECT id AS "id!",
1948  answer_required AS "answer_required!",
1949  order_number AS "order_number!",
1950  peer_or_self_review_config_id AS "peer_or_self_review_config_id!",
1951  question AS "question!",
1952  question_type AS "question_type!: _",
1953  weight AS "weight!"
1954FROM peer_or_self_review_questions
1955WHERE id IN (
1956    SELECT UNNEST($1::uuid [])
1957  )
1958  AND deleted_at is null;
1959        "#,
1960            &ids
1961        )
1962        .fetch_all(&mut *conn)
1963        .await?;
1964
1965        let mut remapped_peer_or_self_review_questions = HashMap::new();
1966
1967        for prq in prqs {
1968            let old_id = new_peer_or_self_review_question_id_to_old_id
1969                .get(&prq.id)
1970                .ok_or_else(|| {
1971                    ModelError::new(
1972                        ModelErrorType::Generic,
1973                        "Inserted peer reviews not found".to_string(),
1974                        None,
1975                    )
1976                })?;
1977            remapped_peer_or_self_review_questions.insert(*old_id, prq);
1978        }
1979
1980        Ok(remapped_peer_or_self_review_questions)
1981    }
1982}
1983
1984/// Only used when testing.
1985pub async fn update_page_content(
1986    conn: &mut PgConnection,
1987    page_id: Uuid,
1988    content: &serde_json::Value,
1989) -> ModelResult<()> {
1990    sqlx::query!(
1991        "
1992UPDATE pages
1993SET content = $1
1994WHERE id = $2;
1995",
1996        content,
1997        page_id
1998    )
1999    .execute(conn)
2000    .await?;
2001    Ok(())
2002}
2003
2004#[derive(Debug)]
2005struct ExerciseTaskIdAndSpec {
2006    pub id: Uuid,
2007    pub private_spec: Option<serde_json::Value>,
2008    pub public_spec: Option<serde_json::Value>,
2009    pub model_solution_spec: Option<serde_json::Value>,
2010}
2011
2012async fn fetch_derived_spec(
2013    existing_exercise_task: Option<&ExerciseTaskIdAndSpec>,
2014    task_update: &NormalizedCmsExerciseTask,
2015    urls_by_exercise_type: &HashMap<&String, Url>,
2016    spec_fetcher: impl SpecFetcher,
2017    previous_spec: Option<serde_json::Value>,
2018    cms_block_id: Uuid,
2019) -> Result<Option<serde_json::Value>, ModelError> {
2020    let result_spec: Option<serde_json::Value> = match existing_exercise_task {
2021        Some(exercise_task) if exercise_task.private_spec == task_update.private_spec => {
2022            // Skip generating public spec for an existing exercise again if private spec is still the same.
2023            previous_spec
2024        }
2025        _ => {
2026            let url = urls_by_exercise_type
2027                .get(&task_update.exercise_type)
2028                .ok_or_else(|| {
2029                    ModelError::new(
2030                        ModelErrorType::PreconditionFailedWithCMSAnchorBlockId {
2031                            id: cms_block_id,
2032                            description: "Missing exercise type for exercise task.",
2033                        },
2034                        "Missing exercise type for exercise task.".to_string(),
2035                        None,
2036                    )
2037                })?
2038                .clone();
2039            let res = spec_fetcher(
2040                url,
2041                &task_update.exercise_type,
2042                task_update.private_spec.as_ref(),
2043            )
2044            .await?;
2045            Some(res)
2046        }
2047    };
2048    Ok(result_spec)
2049}
2050
2051pub async fn insert_new_content_page(
2052    conn: &mut PgConnection,
2053    new_page: NewPage,
2054    user: Uuid,
2055    spec_fetcher: impl SpecFetcher,
2056    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2057) -> ModelResult<Page> {
2058    let mut tx = conn.begin().await?;
2059
2060    let course_material_content = vec![GutenbergBlock::hero_section(new_page.title.trim(), "")];
2061
2062    let content_page = NewPage {
2063        chapter_id: new_page.chapter_id,
2064        content: course_material_content,
2065        course_id: new_page.course_id,
2066        exam_id: None,
2067        front_page_of_chapter_id: None,
2068        title: new_page.title,
2069        url_path: new_page.url_path,
2070        exercises: vec![],
2071        exercise_slides: vec![],
2072        exercise_tasks: vec![],
2073        content_search_language: None,
2074    };
2075    let page = crate::pages::insert_page(
2076        &mut tx,
2077        content_page,
2078        user,
2079        spec_fetcher,
2080        fetch_service_info,
2081    )
2082    .await?;
2083
2084    tx.commit().await?;
2085    Ok(page)
2086}
2087
2088pub async fn insert_page(
2089    conn: &mut PgConnection,
2090    new_page: NewPage,
2091    author: Uuid,
2092    spec_fetcher: impl SpecFetcher,
2093    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2094) -> ModelResult<Page> {
2095    let mut page_language_group_id = None;
2096    if let Some(course_id) = new_page.course_id {
2097        // insert language group
2098        let course = crate::courses::get_course(&mut *conn, course_id).await?;
2099        let new_language_group_id = crate::page_language_groups::insert(
2100            &mut *conn,
2101            crate::PKeyPolicy::Generate,
2102            course.course_language_group_id,
2103        )
2104        .await?;
2105        page_language_group_id = Some(new_language_group_id);
2106    }
2107
2108    let next_order_number = match (new_page.chapter_id, new_page.course_id) {
2109        (Some(id), _) => get_next_page_order_number_in_chapter(conn, id).await?,
2110        (None, Some(course_id)) => {
2111            get_next_order_number_for_courses_top_level_pages(conn, course_id).await?
2112        }
2113        (None, None) => 1,
2114    };
2115
2116    let course: OptionFuture<_> = new_page
2117        .course_id
2118        .map(|id| crate::courses::get_course(conn, id))
2119        .into();
2120    let course = course.await.transpose()?;
2121
2122    let content = replace_duplicate_client_ids(new_page.content.clone());
2123
2124    let mut tx = conn.begin().await?;
2125
2126    let content_search_language = course
2127        .and_then(|c| c.content_search_language)
2128        .or(new_page.content_search_language)
2129        .unwrap_or_else(|| "simple".to_string());
2130    let page = sqlx::query_as!(
2131        Page,
2132        r#"
2133INSERT INTO pages(
2134    course_id,
2135    exam_id,
2136    content,
2137    url_path,
2138    title,
2139    order_number,
2140    chapter_id,
2141    content_search_language,
2142    page_language_group_id
2143  )
2144VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)
2145RETURNING id,
2146  created_at,
2147  updated_at,
2148  course_id,
2149  exam_id,
2150  chapter_id,
2151  url_path,
2152  title,
2153  deleted_at,
2154  content,
2155  order_number,
2156  copied_from,
2157  pages.hidden,
2158  page_language_group_id
2159          "#,
2160        new_page.course_id,
2161        new_page.exam_id,
2162        serde_json::to_value(content)?,
2163        new_page.url_path.trim(),
2164        new_page.title.trim(),
2165        next_order_number,
2166        new_page.chapter_id,
2167        content_search_language as _,
2168        page_language_group_id,
2169    )
2170    .fetch_one(&mut *tx)
2171    .await?;
2172
2173    let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(page.content)?;
2174
2175    let cms_page = update_page(
2176        &mut tx,
2177        PageUpdateArgs {
2178            page_id: page.id,
2179            author,
2180            cms_page_update: CmsPageUpdate {
2181                content: parsed_content,
2182                exercises: new_page.exercises,
2183                exercise_slides: new_page.exercise_slides,
2184                exercise_tasks: new_page.exercise_tasks,
2185                url_path: page.url_path,
2186                title: page.title,
2187                chapter_id: page.chapter_id,
2188            },
2189            retain_ids: false,
2190            history_change_reason: HistoryChangeReason::PageSaved,
2191            is_exam_page: new_page.exam_id.is_some(),
2192        },
2193        spec_fetcher,
2194        fetch_service_info,
2195    )
2196    .await?;
2197
2198    if let Some(front_page_of_chapter_id) = new_page.front_page_of_chapter_id {
2199        let _res = sqlx::query_as!(
2200            DatabaseChapter,
2201            r#"
2202UPDATE chapters
2203SET front_page_id = $1
2204WHERE id = $2
2205RETURNING *;
2206        "#,
2207            page.id,
2208            front_page_of_chapter_id
2209        )
2210        // this should fail if no rows returned
2211        .fetch_one(&mut *tx)
2212        .await?;
2213    }
2214
2215    tx.commit().await?;
2216    Ok(Page {
2217        content: cms_page.page.content,
2218        course_id: page.course_id,
2219        exam_id: page.exam_id,
2220        created_at: page.created_at,
2221        updated_at: page.updated_at,
2222        deleted_at: page.deleted_at,
2223        id: page.id,
2224        title: cms_page.page.title,
2225        url_path: cms_page.page.url_path,
2226        order_number: page.order_number,
2227        chapter_id: page.chapter_id,
2228        copied_from: page.copied_from,
2229        hidden: page.hidden,
2230        page_language_group_id: page.page_language_group_id,
2231    })
2232}
2233
2234pub async fn delete_page_and_exercises(
2235    conn: &mut PgConnection,
2236    page_id: Uuid,
2237    author: Uuid,
2238) -> ModelResult<Page> {
2239    let mut tx = conn.begin().await?;
2240    let page = sqlx::query_as!(
2241        Page,
2242        r#"
2243UPDATE pages
2244SET deleted_at = now()
2245WHERE id = $1
2246AND deleted_at IS NULL
2247RETURNING id,
2248  created_at,
2249  updated_at,
2250  course_id,
2251  exam_id,
2252  chapter_id,
2253  url_path,
2254  title,
2255  deleted_at,
2256  content,
2257  order_number,
2258  copied_from,
2259  hidden,
2260  page_language_group_id
2261          "#,
2262        page_id,
2263    )
2264    .fetch_one(&mut *tx)
2265    .await?;
2266
2267    sqlx::query!(
2268        r#"
2269  UPDATE exercises
2270  SET deleted_at = now()
2271  WHERE page_id = $1
2272  AND deleted_at IS NULL
2273          "#,
2274        page_id,
2275    )
2276    .execute(&mut *tx)
2277    .await?;
2278
2279    sqlx::query!(
2280        "
2281UPDATE exercise_slides
2282SET deleted_at = now()
2283WHERE exercise_id IN (
2284    SELECT id
2285    FROM exercises
2286    WHERE page_id = $1
2287  )
2288  AND deleted_at IS NULL;
2289        ",
2290        page.id
2291    )
2292    .execute(&mut *tx)
2293    .await?;
2294
2295    sqlx::query!(
2296        r#"
2297UPDATE exercise_tasks
2298SET deleted_at = now()
2299WHERE exercise_slide_id IN (
2300    SELECT s.id
2301    FROM exercise_slides s
2302      JOIN exercises e ON (s.exercise_id = e.id)
2303    WHERE e.page_id = $1
2304  )
2305  AND deleted_at IS NULL;
2306            "#,
2307        page.id
2308    )
2309    .execute(&mut *tx)
2310    .await?;
2311
2312    let history_content = PageHistoryContent {
2313        content: json!(null),
2314        exercises: Vec::new(),
2315        exercise_slides: Vec::new(),
2316        exercise_tasks: Vec::new(),
2317        peer_or_self_review_configs: Vec::new(),
2318        peer_or_self_review_questions: Vec::new(),
2319    };
2320    crate::page_history::insert(
2321        &mut tx,
2322        PKeyPolicy::Generate,
2323        page.id,
2324        &page.title,
2325        &history_content,
2326        HistoryChangeReason::PageDeleted,
2327        author,
2328        None,
2329    )
2330    .await?;
2331
2332    tx.commit().await?;
2333    Ok(page)
2334}
2335
2336pub async fn get_chapters_pages_with_exercises(
2337    conn: &mut PgConnection,
2338    chapters_id: Uuid,
2339) -> ModelResult<Vec<PageWithExercises>> {
2340    let chapter_pages = sqlx::query_as!(
2341        Page,
2342        r#"
2343SELECT id,
2344  created_at,
2345  updated_at,
2346  course_id,
2347  exam_id,
2348  chapter_id,
2349  url_path,
2350  title,
2351  deleted_at,
2352  content,
2353  order_number,
2354  copied_from,
2355  hidden,
2356  page_language_group_id
2357FROM pages
2358WHERE chapter_id = $1
2359  AND deleted_at IS NULL
2360        "#,
2361        chapters_id
2362    )
2363    .fetch_all(&mut *conn)
2364    .await?;
2365    let page_ids: Vec<Uuid> = chapter_pages.iter().map(|page| page.id).collect();
2366    let pages_exercises = sqlx::query_as!(
2367        Exercise,
2368        r#"
2369SELECT *
2370FROM exercises
2371WHERE page_id IN (
2372    SELECT UNNEST($1::uuid [])
2373  )
2374  AND deleted_at IS NULL
2375        "#,
2376        &page_ids
2377    )
2378    .fetch_all(&mut *conn)
2379    .await?;
2380
2381    let mut page_to_exercises: HashMap<Uuid, Vec<Exercise>> = pages_exercises
2382        .into_iter()
2383        .into_group_map_by(|exercise| exercise.page_id);
2384    let mut chapter_pages_with_exercises: Vec<PageWithExercises> = chapter_pages
2385        .into_iter()
2386        .map(|page| {
2387            let page_id = page.id;
2388            let mut exercises = page_to_exercises.remove(&page_id).unwrap_or_default();
2389
2390            exercises.sort_by(|a, b| a.order_number.cmp(&b.order_number));
2391            PageWithExercises { page, exercises }
2392        })
2393        .collect();
2394
2395    chapter_pages_with_exercises.sort_by(|a, b| a.page.order_number.cmp(&b.page.order_number));
2396
2397    Ok(chapter_pages_with_exercises)
2398}
2399
2400pub async fn get_next_page(
2401    conn: &mut PgConnection,
2402    page_id: Uuid,
2403) -> ModelResult<Option<PageRoutingData>> {
2404    let page_metadata = get_current_page_metadata(conn, page_id).await?;
2405    let next_page = get_next_page_by_order_number(conn, &page_metadata).await?;
2406
2407    match next_page {
2408        Some(next_page) => Ok(Some(next_page)),
2409        None => {
2410            let first_page = get_next_page_by_chapter_number(conn, &page_metadata).await?;
2411            Ok(first_page)
2412        }
2413    }
2414}
2415
2416async fn get_current_page_metadata(
2417    conn: &mut PgConnection,
2418    page_id: Uuid,
2419) -> ModelResult<PageMetadata> {
2420    let page_metadata = sqlx::query_as!(
2421        PageMetadata,
2422        r#"
2423SELECT p.id as page_id,
2424  p.order_number as order_number,
2425  p.course_id as course_id,
2426  p.exam_id as exam_id,
2427  c.id as "chapter_id?",
2428  c.chapter_number as "chapter_number?"
2429FROM pages p
2430  LEFT JOIN chapters c ON p.chapter_id = c.id
2431WHERE p.id = $1;
2432"#,
2433        page_id
2434    )
2435    .fetch_one(conn)
2436    .await?;
2437
2438    if page_metadata.chapter_number.is_none() {
2439        return Err(ModelError::new(
2440            ModelErrorType::InvalidRequest,
2441            "Page is not related to any chapter".to_string(),
2442            None,
2443        ));
2444    }
2445
2446    Ok(page_metadata)
2447}
2448
2449async fn get_next_page_by_order_number(
2450    conn: &mut PgConnection,
2451    current_page_metadata: &PageMetadata,
2452) -> ModelResult<Option<PageRoutingData>> {
2453    let next_page = sqlx::query_as!(
2454        PageRoutingData,
2455        "
2456SELECT p.url_path as url_path,
2457  p.title as title,
2458  p.id as page_id,
2459  c.chapter_number as chapter_number,
2460  c.id as chapter_id,
2461  c.opens_at as chapter_opens_at,
2462  c.front_page_id as chapter_front_page_id
2463FROM pages p
2464  LEFT JOIN chapters c ON p.chapter_id = c.id
2465WHERE p.order_number = (
2466    SELECT MIN(pa.order_number)
2467    FROM pages pa
2468    WHERE pa.order_number > $1
2469      AND pa.deleted_at IS NULL
2470  )
2471  AND p.course_id = $2
2472  AND c.chapter_number = $3
2473  AND p.deleted_at IS NULL;
2474        ",
2475        current_page_metadata.order_number,
2476        current_page_metadata.course_id,
2477        current_page_metadata.chapter_number
2478    )
2479    .fetch_optional(conn)
2480    .await?;
2481
2482    Ok(next_page)
2483}
2484
2485async fn get_next_page_by_chapter_number(
2486    conn: &mut PgConnection,
2487    current_page_metadata: &PageMetadata,
2488) -> ModelResult<Option<PageRoutingData>> {
2489    let next_page = sqlx::query_as!(
2490        PageRoutingData,
2491        "
2492SELECT p.url_path as url_path,
2493  p.title as title,
2494  p.id as page_id,
2495  c.chapter_number as chapter_number,
2496  c.id as chapter_id,
2497  c.opens_at as chapter_opens_at,
2498  c.front_page_id as chapter_front_page_id
2499FROM chapters c
2500  INNER JOIN pages p on c.id = p.chapter_id
2501WHERE c.chapter_number = (
2502    SELECT MIN(ca.chapter_number)
2503    FROM chapters ca
2504    WHERE ca.chapter_number > $1
2505      AND ca.deleted_at IS NULL
2506  )
2507  AND c.course_id = $2
2508  AND p.deleted_at IS NULL
2509ORDER BY p.order_number
2510LIMIT 1;
2511        ",
2512        current_page_metadata.chapter_number,
2513        current_page_metadata.course_id
2514    )
2515    .fetch_optional(conn)
2516    .await?;
2517
2518    Ok(next_page)
2519}
2520
2521async fn get_next_page_order_number_in_chapter(
2522    conn: &mut PgConnection,
2523    chapter_id: Uuid,
2524) -> ModelResult<i32> {
2525    let next_order_number = sqlx::query!(
2526        "
2527select max(p.order_number) as order_number
2528from pages p
2529where p.chapter_id = $1
2530  and p.deleted_at is null;
2531",
2532        chapter_id
2533    )
2534    .fetch_one(conn)
2535    .await?;
2536
2537    match next_order_number.order_number {
2538        Some(order_number) => Ok(order_number + 1),
2539        None => Ok(0),
2540    }
2541}
2542
2543pub async fn get_page_navigation_data(
2544    conn: &mut PgConnection,
2545    page_id: Uuid,
2546) -> ModelResult<PageNavigationInformation> {
2547    let previous_page_data = get_previous_page(conn, page_id).await?;
2548
2549    let next_page_data = get_next_page(conn, page_id).await?;
2550
2551    let chapter_front_page = get_chapter_front_page_by_page_id(conn, page_id).await?;
2552    // 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.
2553    let chapter_front_page_chapter = OptionFuture::from(
2554        chapter_front_page
2555            .clone()
2556            .and_then(|front_page| front_page.chapter_id)
2557            .map(|chapter_id| chapters::get_chapter(conn, chapter_id)),
2558    )
2559    .await
2560    .transpose()?;
2561
2562    let chapter_front_page = chapter_front_page
2563        .map(|front_page| -> ModelResult<_> {
2564            if let Some(chapter_front_page_chapter) = chapter_front_page_chapter {
2565                Ok(PageRoutingData {
2566                    url_path: front_page.url_path,
2567                    title: front_page.title,
2568                    page_id: front_page.id,
2569                    chapter_number: chapter_front_page_chapter.chapter_number,
2570                    chapter_id: chapter_front_page_chapter.id,
2571                    chapter_opens_at: chapter_front_page_chapter.opens_at,
2572                    chapter_front_page_id: Some(front_page.id),
2573                })
2574            } else {
2575                Err(ModelError::new(
2576                    ModelErrorType::InvalidRequest,
2577                    "Chapter front page chapter not found".to_string(),
2578                    None,
2579                ))
2580            }
2581        })
2582        .transpose()?;
2583    Ok(PageNavigationInformation {
2584        chapter_front_page,
2585        next_page: next_page_data,
2586        previous_page: previous_page_data,
2587    })
2588}
2589
2590pub async fn get_previous_page(
2591    conn: &mut PgConnection,
2592    page_id: Uuid,
2593) -> ModelResult<Option<PageRoutingData>> {
2594    let page_metadata = get_current_page_metadata(conn, page_id).await?;
2595    let previous_page = get_previous_page_by_order_number(conn, &page_metadata).await?;
2596
2597    match previous_page {
2598        Some(previous_page) => Ok(Some(previous_page)),
2599        None => {
2600            let first_page = get_previous_page_by_chapter_number(conn, &page_metadata).await?;
2601            Ok(first_page)
2602        }
2603    }
2604}
2605
2606pub async fn get_chapter_front_page_by_page_id(
2607    conn: &mut PgConnection,
2608    page_id: Uuid,
2609) -> ModelResult<Option<Page>> {
2610    let page_metadata = get_current_page_metadata(conn, page_id).await?;
2611    let chapter = chapters::get_chapter_info_by_page_metadata(conn, &page_metadata).await?;
2612    let page_option_future: OptionFuture<_> = chapter
2613        .chapter_front_page_id
2614        .map(|chapter_front_page_id| get_page(conn, chapter_front_page_id))
2615        .into();
2616    let page = page_option_future.await.transpose()?;
2617    Ok(page)
2618}
2619
2620async fn get_previous_page_by_order_number(
2621    conn: &mut PgConnection,
2622    current_page_metadata: &PageMetadata,
2623) -> ModelResult<Option<PageRoutingData>> {
2624    let previous_page = sqlx::query_as!(
2625        PageRoutingData,
2626        "
2627SELECT p.url_path as url_path,
2628  p.title as title,
2629  c.chapter_number as chapter_number,
2630  p.id as page_id,
2631  c.id as chapter_id,
2632  c.opens_at as chapter_opens_at,
2633  c.front_page_id as chapter_front_page_id
2634FROM pages p
2635  LEFT JOIN chapters c ON p.chapter_id = c.id
2636WHERE p.order_number = (
2637    SELECT MAX(pa.order_number)
2638    FROM pages pa
2639    WHERE pa.order_number < $1
2640      AND pa.deleted_at IS NULL
2641  )
2642  AND p.course_id = $2
2643  AND c.chapter_number = $3
2644  AND p.deleted_at IS NULL;
2645        ",
2646        current_page_metadata.order_number,
2647        current_page_metadata.course_id,
2648        current_page_metadata.chapter_number
2649    )
2650    .fetch_optional(conn)
2651    .await?;
2652
2653    Ok(previous_page)
2654}
2655
2656async fn get_previous_page_by_chapter_number(
2657    conn: &mut PgConnection,
2658    current_page_metadata: &PageMetadata,
2659) -> ModelResult<Option<PageRoutingData>> {
2660    let previous_page = sqlx::query_as!(
2661        PageRoutingData,
2662        "
2663SELECT p.url_path AS url_path,
2664  p.title AS title,
2665  p.id AS page_id,
2666  c.chapter_number AS chapter_number,
2667  c.id AS chapter_id,
2668  c.opens_at AS chapter_opens_at,
2669  c.front_page_id AS chapter_front_page_id
2670FROM chapters c
2671  INNER JOIN pages p ON c.id = p.chapter_id
2672WHERE c.chapter_number = (
2673    SELECT MAX(ca.chapter_number)
2674    FROM chapters ca
2675    WHERE ca.chapter_number < $1
2676      AND ca.deleted_at IS NULL
2677  )
2678  AND c.course_id = $2
2679  AND p.deleted_at IS NULL
2680ORDER BY p.order_number DESC
2681LIMIT 1;
2682        ",
2683        current_page_metadata.chapter_number,
2684        current_page_metadata.course_id
2685    )
2686    .fetch_optional(conn)
2687    .await?;
2688
2689    Ok(previous_page)
2690}
2691
2692async fn get_next_order_number_for_courses_top_level_pages(
2693    conn: &mut PgConnection,
2694    course_id: Uuid,
2695) -> ModelResult<i32> {
2696    let next_order_number = sqlx::query!(
2697        "
2698select max(p.order_number) as order_number
2699from pages p
2700where p.course_id = $1
2701  and p.chapter_id is null
2702  and p.deleted_at is null;
2703",
2704        course_id
2705    )
2706    .fetch_one(conn)
2707    .await?;
2708
2709    match next_order_number.order_number {
2710        Some(order_number) => Ok(order_number + 1),
2711        None => Ok(0),
2712    }
2713}
2714
2715pub async fn get_chapter_pages(
2716    conn: &mut PgConnection,
2717    chapter_id: Uuid,
2718) -> ModelResult<Vec<Page>> {
2719    let pages = sqlx::query_as!(
2720        Page,
2721        "
2722SELECT id,
2723  created_at,
2724  updated_at,
2725  course_id,
2726  exam_id,
2727  chapter_id,
2728  url_path,
2729  title,
2730  deleted_at,
2731  content,
2732  order_number,
2733  copied_from,
2734  hidden,
2735  page_language_group_id
2736FROM pages p
2737WHERE p.chapter_id = $1
2738  AND p.deleted_at IS NULL;
2739    ",
2740        chapter_id
2741    )
2742    .fetch_all(conn)
2743    .await?;
2744
2745    Ok(pages)
2746}
2747
2748pub async fn get_chapters_visible_pages_exclude_main_frontpage(
2749    conn: &mut PgConnection,
2750    chapter_id: Uuid,
2751) -> ModelResult<Vec<Page>> {
2752    let pages = sqlx::query_as!(
2753        Page,
2754        "
2755SELECT id,
2756  created_at,
2757  updated_at,
2758  course_id,
2759  exam_id,
2760  chapter_id,
2761  url_path,
2762  title,
2763  deleted_at,
2764  content,
2765  order_number,
2766  copied_from,
2767  hidden,
2768  page_language_group_id
2769FROM pages p
2770WHERE p.chapter_id = $1
2771  AND p.deleted_at IS NULL
2772  AND p.hidden IS FALSE
2773  AND p.url_path IS NOT NULL
2774  AND p.id NOT IN (
2775    SELECT front_page_id
2776    FROM chapters c
2777    WHERE c.front_page_id = p.id
2778  );
2779    ",
2780        chapter_id
2781    )
2782    .fetch_all(conn)
2783    .await?;
2784    Ok(pages)
2785}
2786
2787/**
2788Returns search results for a phrase i.e. looks for matches where the words come up right after each other
2789*/
2790pub async fn get_page_search_results_for_phrase(
2791    conn: &mut PgConnection,
2792    course_id: Uuid,
2793    page_search_request: &SearchRequest,
2794) -> ModelResult<Vec<PageSearchResult>> {
2795    let course = crate::courses::get_course(&mut *conn, course_id).await?;
2796
2797    // Last word of the search term needed so that the sql statement can change it to a prefix match.
2798    // Allows the last word to not be fully typed.
2799    let last_word = if let Some(last) = page_search_request
2800        .query
2801        .trim()
2802        .split_ascii_whitespace()
2803        .last()
2804    {
2805        last
2806    } else {
2807        return Ok(Vec::new());
2808    };
2809
2810    let res =   sqlx::query_as!(
2811            PageSearchResult,
2812            r#"
2813-- common table expression for the search term tsquery so that we don't have to repeat it many times
2814WITH cte as (
2815    -- Converts the search term to a phrase search with phraseto_tsquery but appends ':*' to the last word so that it
2816    -- becomes a prefix match. This way the search will also contain results when the last word in the search term
2817    -- is only partially typed. Note that if to_tsquery($4) decides to stem the word, the replacement will be skipped.
2818    SELECT ts_rewrite(
2819        phraseto_tsquery($2::regconfig, $3),
2820        to_tsquery($4),
2821        to_tsquery($4 || ':*')
2822    ) as query
2823)
2824SELECT p.id,
2825    ts_rank(
2826    p.content_search,
2827    (
2828        SELECT query
2829        from cte
2830    )
2831    ) as rank,
2832    ts_headline(
2833    $2::regconfig,
2834    p.title,
2835    (
2836        SELECT query
2837        from cte
2838    ),
2839    'MaxFragments=1, MaxWords=20, MinWords=1'
2840    ) as title_headline,
2841    ts_headline(
2842    $2::regconfig,
2843    p.content_search_original_text,
2844    (
2845        SELECT query
2846        from cte
2847    ),
2848    'MaxFragments=0, MaxWords=120, MinWords=70'
2849    ) as content_headline,
2850    COALESCE(p.url_path, '') as "url_path!",
2851    c.name as "chapter_name?"
2852FROM pages p
2853LEFT JOIN chapters c ON p.chapter_id = c.id
2854WHERE p.course_id = $1
2855    AND p.deleted_at IS NULL
2856    AND p.hidden IS FALSE
2857    AND p.url_path IS NOT NULL
2858    AND p.content_search @@ (
2859    SELECT query
2860    from cte
2861    )
2862ORDER BY rank DESC
2863LIMIT 50;
2864        "#,
2865            course_id,
2866            course.content_search_language as _,
2867            page_search_request.query,
2868            last_word
2869        )
2870        .fetch_all(conn)
2871        .await?;
2872
2873    Ok(add_course_url_prefix_to_search_results(res, &course))
2874}
2875
2876/**
2877Returns search results for the given words. The words can appear in the source document in any order.
2878*/
2879pub async fn get_page_search_results_for_words(
2880    conn: &mut PgConnection,
2881    course_id: Uuid,
2882    page_search_request: &SearchRequest,
2883) -> ModelResult<Vec<PageSearchResult>> {
2884    let course = crate::courses::get_course(&mut *conn, course_id).await?;
2885
2886    // Last word of the search term needed so that the sql statement can change it to a prefix match.
2887    // Allows the last word to not be fully typed.
2888    let last_word = if let Some(last) = page_search_request
2889        .query
2890        .trim()
2891        .split_ascii_whitespace()
2892        .last()
2893    {
2894        last
2895    } else {
2896        return Ok(Vec::new());
2897    };
2898
2899    let res = sqlx::query_as!(
2900            PageSearchResult,
2901            r#"
2902-- common table expression for the search term tsquery so that we don't have to repeat it many times
2903WITH cte as (
2904    -- Converts the search term to a word search with ands between the words with plainto_tsquery but appends ':*' to the
2905    -- last word so that it  becomes a prefix match. This way the search will also contain results when the last word in
2906    -- the search term is only partially typed. Note that if to_tsquery($4) decides to stem the word, the replacement
2907    -- will be skipped.
2908    SELECT ts_rewrite(
2909        plainto_tsquery($2::regconfig, $3),
2910        to_tsquery($4),
2911        to_tsquery($4 || ':*')
2912    ) as query
2913)
2914SELECT p.id,
2915    ts_rank(
2916    p.content_search,
2917    (
2918        SELECT query
2919        from cte
2920    )
2921    ) as rank,
2922    ts_headline(
2923    $2::regconfig,
2924    p.title,
2925    (
2926        SELECT query
2927        from cte
2928    ),
2929    'MaxFragments=1, MaxWords=20, MinWords=1'
2930    ) as title_headline,
2931    ts_headline(
2932    $2::regconfig,
2933    p.content_search_original_text,
2934    (
2935        SELECT query
2936        from cte
2937    ),
2938    'MaxFragments=0, MaxWords=120, MinWords=70'
2939    ) as content_headline,
2940    COALESCE(p.url_path, '') as "url_path!",
2941    c.name as "chapter_name?"
2942FROM pages p
2943LEFT JOIN chapters c ON p.chapter_id = c.id
2944WHERE p.course_id = $1
2945    AND p.deleted_at IS NULL
2946    AND p.hidden IS FALSE
2947    AND p.url_path IS NOT NULL
2948    AND p.content_search @@ (
2949    SELECT query
2950    from cte
2951    )
2952ORDER BY rank DESC
2953LIMIT 50;
2954        "#,
2955            course_id,
2956            course.content_search_language as _,
2957            page_search_request.query,
2958            last_word
2959        )
2960        .fetch_all(conn)
2961        .await?;
2962
2963    Ok(add_course_url_prefix_to_search_results(res, &course))
2964}
2965
2966fn add_course_url_prefix_to_search_results(
2967    search_results: Vec<PageSearchResult>,
2968    course: &Course,
2969) -> Vec<PageSearchResult> {
2970    search_results
2971        .into_iter()
2972        .map(|mut sr| {
2973            let optional_slash = if sr.url_path.starts_with('/') {
2974                ""
2975            } else {
2976                "/"
2977            };
2978            sr.url_path = format!("/{}{}{}", course.slug, optional_slash, sr.url_path);
2979            sr
2980        })
2981        .collect()
2982}
2983
2984/// Restore page contents and exercises to a previous revision
2985pub async fn restore(
2986    conn: &mut PgConnection,
2987    page_id: Uuid,
2988    history_id: Uuid,
2989    author: Uuid,
2990    spec_fetcher: impl SpecFetcher,
2991    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2992) -> ModelResult<Uuid> {
2993    // fetch old content
2994    let page = get_page(conn, page_id).await?;
2995    let history_data = page_history::get_history_data(conn, history_id).await?;
2996
2997    let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(history_data.content.content)?;
2998
2999    update_page(
3000        conn,
3001        PageUpdateArgs {
3002            page_id: page.id,
3003            author,
3004            cms_page_update: CmsPageUpdate {
3005                content: parsed_content,
3006                exercises: history_data.content.exercises,
3007                exercise_slides: history_data.content.exercise_slides,
3008                exercise_tasks: history_data.content.exercise_tasks,
3009                url_path: page.url_path,
3010                title: history_data.title,
3011                chapter_id: page.chapter_id,
3012            },
3013            retain_ids: true,
3014            history_change_reason: HistoryChangeReason::HistoryRestored,
3015            is_exam_page: history_data.exam_id.is_some(),
3016        },
3017        spec_fetcher,
3018        fetch_service_info,
3019    )
3020    .await?;
3021
3022    Ok(history_id)
3023}
3024
3025pub async fn get_organization_id(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<Uuid> {
3026    let res = sqlx::query!(
3027        "
3028SELECT organizations.id
3029FROM pages
3030  LEFT OUTER JOIN courses ON courses.id = pages.course_id
3031  LEFT OUTER JOIN exams ON exams.id = pages.exam_id
3032  JOIN organizations ON organizations.id = courses.organization_id
3033  OR organizations.id = exams.organization_id
3034WHERE pages.id = $1
3035",
3036        page_id,
3037    )
3038    .fetch_one(&mut *conn)
3039    .await?;
3040    Ok(res.id)
3041}
3042
3043pub async fn get_page_chapter_and_course_information(
3044    conn: &mut PgConnection,
3045    page_id: Uuid,
3046) -> ModelResult<PageChapterAndCourseInformation> {
3047    let res = sqlx::query_as!(
3048        PageChapterAndCourseInformation,
3049        r#"
3050SELECT chapters.name as "chapter_name?",
3051  chapters.chapter_number as "chapter_number?",
3052  courses.name as "course_name?",
3053  courses.slug as "course_slug?",
3054  chapters.front_page_id as "chapter_front_page_id?",
3055  p2.url_path as "chapter_front_page_url_path?",
3056  organizations.slug as organization_slug
3057FROM pages
3058  LEFT JOIN chapters on pages.chapter_id = chapters.id
3059  LEFT JOIN courses on pages.course_id = courses.id
3060  LEFT JOIN pages p2 ON chapters.front_page_id = p2.id
3061  LEFT JOIN organizations on courses.organization_id = organizations.id
3062WHERE pages.id = $1
3063"#,
3064        page_id,
3065    )
3066    .fetch_one(&mut *conn)
3067    .await?;
3068    Ok(res)
3069}
3070
3071pub async fn get_page_by_course_id_and_language_group(
3072    conn: &mut PgConnection,
3073    course_id: Uuid,
3074    page_language_group_id: Uuid,
3075) -> ModelResult<Page> {
3076    let page = sqlx::query_as!(
3077        Page,
3078        "
3079SELECT id,
3080    created_at,
3081    updated_at,
3082    course_id,
3083    exam_id,
3084    chapter_id,
3085    url_path,
3086    title,
3087    deleted_at,
3088    content,
3089    order_number,
3090    copied_from,
3091    hidden,
3092    page_language_group_id
3093FROM pages p
3094WHERE p.course_id = $1
3095    AND p.page_language_group_id = $2
3096    AND p.deleted_at IS NULL
3097    ",
3098        course_id,
3099        page_language_group_id
3100    )
3101    .fetch_one(&mut *conn)
3102    .await?;
3103    Ok(page)
3104}
3105
3106/// Makes the order numbers and chapter ids to match in the db what's in the page objects
3107/// Assumes that all pages belong to the given course id
3108/// 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
3109pub async fn reorder_pages(
3110    conn: &mut PgConnection,
3111    pages: &[Page],
3112    course_id: Uuid,
3113) -> ModelResult<()> {
3114    let db_pages =
3115        get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
3116    let chapters = course_chapters(conn, course_id).await?;
3117
3118    let mut chapter_pages: HashMap<Option<Uuid>, Vec<&Page>> = HashMap::new();
3119
3120    for page in pages {
3121        chapter_pages.entry(page.chapter_id).or_default().push(page);
3122    }
3123
3124    let mut normalized_pages = Vec::with_capacity(pages.len());
3125
3126    for (_, chapter_pages) in chapter_pages.iter() {
3127        // Sort by order_number and then by id for consistency
3128        let mut sorted_pages = chapter_pages.to_vec();
3129        sorted_pages.sort_by(|a, b| {
3130            a.order_number
3131                .cmp(&b.order_number)
3132                .then_with(|| a.id.cmp(&b.id))
3133        });
3134
3135        // Create normalized pages with sequential order numbers
3136        for (idx, &page) in sorted_pages.iter().enumerate() {
3137            let mut normalized_page = page.clone();
3138            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
3139            normalized_pages.push(normalized_page);
3140        }
3141    }
3142
3143    let mut tx = conn.begin().await?;
3144
3145    // First, randomize ALL page order numbers to avoid conflicts (except chapter front pages, which cannot be moved)
3146    // This is necessary because unique indexes cannot be deferred in PostgreSQL
3147    // The random numbers are temporary and will be replaced with the correct order numbers
3148    sqlx::query!(
3149        "
3150UPDATE pages
3151SET order_number = floor(random() * (2000000 -200000 + 1) + 200000)
3152WHERE course_id = $1
3153  AND order_number != 0
3154  AND deleted_at IS NULL
3155        ",
3156        course_id
3157    )
3158    .execute(&mut *tx)
3159    .await?;
3160
3161    // Now update each page to its corrected order_number, using the normalized pages
3162    for page in normalized_pages {
3163        if let Some(matching_db_page) = db_pages.iter().find(|p| p.id == page.id) {
3164            if matching_db_page.chapter_id == page.chapter_id {
3165                // Chapter not changing - just set the corrected order number
3166                sqlx::query!(
3167                    "UPDATE pages SET order_number = $2 WHERE id = $1",
3168                    page.id,
3169                    page.order_number
3170                )
3171                .execute(&mut *tx)
3172                .await?;
3173            } else {
3174                // Chapter changes - handle URL paths and redirections
3175                if let Some(old_chapter_id) = matching_db_page.chapter_id {
3176                    if let Some(new_chapter_id) = page.chapter_id {
3177                        // Moving page to another chapter
3178                        if let Some(old_chapter) = chapters.iter().find(|o| o.id == old_chapter_id)
3179                        {
3180                            if let Some(new_chapter) =
3181                                chapters.iter().find(|o| o.id == new_chapter_id)
3182                            {
3183                                let old_path = &page.url_path;
3184                                let new_path = old_path.replacen(
3185                                    &old_chapter.chapter_number.to_string(),
3186                                    &new_chapter.chapter_number.to_string(),
3187                                    1,
3188                                );
3189                                sqlx::query!(
3190                                    "UPDATE pages SET url_path = $2, chapter_id = $3, order_number = $4 WHERE pages.id = $1",
3191                                    page.id,
3192                                    new_path,
3193                                    new_chapter.id,
3194                                    page.order_number
3195                                )
3196                                .execute(&mut *tx)
3197                                .await?;
3198                                sqlx::query!(
3199                                    "INSERT INTO url_redirections(destination_page_id, old_url_path, course_id) VALUES ($1, $2, $3)",
3200                                    page.id,
3201                                    old_path,
3202                                    course_id
3203                                )
3204                                .execute(&mut *tx)
3205                                .await?;
3206                            } else {
3207                                return Err(ModelError::new(
3208                                    ModelErrorType::InvalidRequest,
3209                                    "New chapter not found".to_string(),
3210                                    None,
3211                                ));
3212                            }
3213                        } else {
3214                            return Err(ModelError::new(
3215                                ModelErrorType::InvalidRequest,
3216                                "Old chapter not found".to_string(),
3217                                None,
3218                            ));
3219                        }
3220                    } else {
3221                        // Moving page from a chapter to a top level page
3222                        return Err(ModelError::new(
3223                            ModelErrorType::InvalidRequest,
3224                            "Making a chapter page a top level page is not supported yet"
3225                                .to_string(),
3226                            None,
3227                        ));
3228                    }
3229                } else {
3230                    error!(
3231                        "Cannot move a top level page to a chapter. matching_db_page.chapter_id: {:?} page.chapter_id: {:?}",
3232                        matching_db_page.chapter_id, page.chapter_id
3233                    );
3234                    // Moving page from the top level to a chapter
3235                    return Err(ModelError::new(
3236                        ModelErrorType::InvalidRequest,
3237                        "Moving a top level page to a chapter is not supported yet".to_string(),
3238                        None,
3239                    ));
3240                }
3241            }
3242        } else {
3243            return Err(ModelError::new(
3244                ModelErrorType::InvalidRequest,
3245                format!("Page {} does exist in course {}", page.id, course_id),
3246                None,
3247            ));
3248        }
3249    }
3250    tx.commit().await?;
3251    Ok(())
3252}
3253
3254pub async fn reorder_chapters(
3255    conn: &mut PgConnection,
3256    chapters: &[Chapter],
3257    course_id: Uuid,
3258) -> ModelResult<()> {
3259    let db_chapters = course_chapters(conn, course_id).await?;
3260    let mut tx = conn.begin().await?;
3261    // Look for the modified chapter in the existing database
3262
3263    // TODO USE CHAPTER ID FOR THE LOOP
3264    for chapter in chapters {
3265        if let Some(matching_db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id)
3266            && let Some(old_chapter) = db_chapters.iter().find(|o| o.id == matching_db_chapter.id)
3267        {
3268            // to avoid conflicting chapter_number when chapter is modified
3269            //Assign random number to modified chapters
3270            sqlx::query!(
3271                "UPDATE chapters
3272                SET chapter_number = floor(random() * (20000000 - 2000000 + 1) + 200000)
3273                WHERE chapters.id = $1
3274                  AND chapters.course_id = $2
3275                  AND deleted_at IS NULL",
3276                matching_db_chapter.id,
3277                course_id
3278            )
3279            .execute(&mut *tx)
3280            .await?;
3281
3282            // get newly modified chapter
3283            let chapter_with_randomized_chapter_number =
3284                get_chapter(&mut tx, matching_db_chapter.id).await?;
3285            let random_chapter_number = chapter_with_randomized_chapter_number.chapter_number;
3286            let pages =
3287                get_chapter_pages(&mut tx, chapter_with_randomized_chapter_number.id).await?;
3288
3289            for page in pages {
3290                let old_path = &page.url_path;
3291                let new_path = old_path.replacen(
3292                    &old_chapter.chapter_number.to_string(),
3293                    &random_chapter_number.to_string(),
3294                    1,
3295                );
3296
3297                // update each page path associated with a random chapter number
3298                sqlx::query!(
3299                    "UPDATE pages SET url_path = $2 WHERE pages.id = $1",
3300                    page.id,
3301                    new_path
3302                )
3303                .execute(&mut *tx)
3304                .await?;
3305            }
3306        }
3307    }
3308
3309    for chapter in chapters {
3310        if let Some(matching_db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id) {
3311            if let Some(new_chapter) = chapters.iter().find(|o| o.id == matching_db_chapter.id) {
3312                let new_chapter_number = &new_chapter.chapter_number;
3313
3314                let randomized_chapter = get_chapter(&mut tx, chapter.id).await?;
3315
3316                let randomized_chapter_number = randomized_chapter.chapter_number;
3317
3318                // update chapter_number
3319                sqlx::query!(
3320                    "UPDATE chapters SET chapter_number = $2 WHERE chapters.id = $1",
3321                    chapter.id,
3322                    new_chapter_number
3323                )
3324                .execute(&mut *tx)
3325                .await?;
3326
3327                // update all pages url in the modified chapter
3328                let pages = get_chapter_pages(&mut tx, chapter.id).await?;
3329
3330                for page in pages {
3331                    let path_with_temp_random_number = &page.url_path;
3332                    let new_path = path_with_temp_random_number.replacen(
3333                        &randomized_chapter_number.to_string(),
3334                        &new_chapter_number.to_string(),
3335                        1,
3336                    );
3337                    let old_path = path_with_temp_random_number.replacen(
3338                        &randomized_chapter_number.to_string(),
3339                        &chapter.chapter_number.to_string(),
3340                        1,
3341                    );
3342                    // update each page path associated with the modified chapter
3343                    sqlx::query!(
3344                        "UPDATE pages SET url_path = $2 WHERE pages.id = $1",
3345                        page.id,
3346                        new_path
3347                    )
3348                    .execute(&mut *tx)
3349                    .await?;
3350
3351                    crate::url_redirections::upsert(
3352                        &mut tx,
3353                        PKeyPolicy::Generate,
3354                        page.id,
3355                        &old_path,
3356                        course_id,
3357                    )
3358                    .await?;
3359                }
3360            } else {
3361                return Err(ModelError::new(
3362                    ModelErrorType::InvalidRequest,
3363                    "New chapter not found".to_string(),
3364                    None,
3365                ));
3366            }
3367        } else {
3368            return Err(ModelError::new(
3369                ModelErrorType::InvalidRequest,
3370                "Matching DB chapters not found".to_string(),
3371                None,
3372            ));
3373        }
3374    }
3375
3376    tx.commit().await?;
3377    Ok(())
3378}
3379
3380pub async fn is_chapter_front_page(
3381    conn: &mut PgConnection,
3382    page_id: Uuid,
3383) -> ModelResult<IsChapterFrontPage> {
3384    let chapter = get_chapter_by_page_id(conn, page_id).await?;
3385
3386    Ok(chapter.front_page_id.map_or(
3387        IsChapterFrontPage {
3388            is_chapter_front_page: false,
3389        },
3390        |id| IsChapterFrontPage {
3391            is_chapter_front_page: id == page_id,
3392        },
3393    ))
3394}
3395
3396pub async fn update_page_details(
3397    conn: &mut PgConnection,
3398    page_id: Uuid,
3399    page_details_update: &PageDetailsUpdate,
3400) -> ModelResult<()> {
3401    let mut tx = conn.begin().await?;
3402    let page_before_update = get_page(&mut tx, page_id).await?;
3403    sqlx::query!(
3404        "
3405UPDATE pages
3406SET title = $2,
3407  url_path = $3
3408WHERE id = $1
3409",
3410        page_id,
3411        page_details_update.title,
3412        page_details_update.url_path,
3413    )
3414    .execute(&mut *tx)
3415    .await?;
3416
3417    if let Some(course_id) = page_before_update.course_id
3418        && page_before_update.url_path != page_details_update.url_path
3419    {
3420        // Some students might be trying to reach the page with the old url path, so let's redirect them to the new one
3421        crate::url_redirections::upsert(
3422            &mut tx,
3423            PKeyPolicy::Generate,
3424            page_id,
3425            &page_before_update.url_path,
3426            course_id,
3427        )
3428        .await?;
3429    }
3430
3431    tx.commit().await?;
3432    Ok(())
3433}
3434
3435pub async fn get_by_ids_and_visibility(
3436    conn: &mut PgConnection,
3437    ids: &[Uuid],
3438    page_visibility: PageVisibility,
3439) -> ModelResult<Vec<Page>> {
3440    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
3441    let pages = sqlx::query_as!(
3442        Page,
3443        "
3444SELECT id,
3445    created_at,
3446    updated_at,
3447    course_id,
3448    exam_id,
3449    chapter_id,
3450    url_path,
3451    title,
3452    deleted_at,
3453    content,
3454    order_number,
3455    copied_from,
3456    hidden,
3457    page_language_group_id
3458FROM pages
3459WHERE id = ANY($1)
3460    AND hidden IS DISTINCT FROM $2
3461    AND deleted_at IS NULL
3462    ",
3463        ids,
3464        inverse_visibility_filter
3465    )
3466    .fetch_all(conn)
3467    .await?;
3468    Ok(pages)
3469}
3470
3471pub async fn get_by_ids_deleted_and_visibility(
3472    conn: &mut PgConnection,
3473    ids: &[Uuid],
3474    page_visibility: PageVisibility,
3475) -> ModelResult<Vec<Page>> {
3476    let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
3477    let pages = sqlx::query_as!(
3478        Page,
3479        "
3480SELECT id,
3481    created_at,
3482    updated_at,
3483    course_id,
3484    exam_id,
3485    chapter_id,
3486    url_path,
3487    title,
3488    deleted_at,
3489    content,
3490    order_number,
3491    copied_from,
3492    hidden,
3493    page_language_group_id
3494FROM pages
3495WHERE id = ANY($1)
3496    AND hidden IS DISTINCT FROM $2
3497    AND deleted_at IS NOT NULL
3498    ",
3499        ids,
3500        inverse_visibility_filter
3501    )
3502    .fetch_all(conn)
3503    .await?;
3504    Ok(pages)
3505}
3506
3507#[cfg(test)]
3508mod test {
3509    use chrono::TimeZone;
3510
3511    use super::*;
3512    use crate::{exams::NewExam, test_helper::*};
3513
3514    #[tokio::test]
3515    async fn gets_organization_id() {
3516        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, :page);
3517
3518        let course_page_org = get_organization_id(tx.as_mut(), page).await.unwrap();
3519        assert_eq!(org, course_page_org);
3520
3521        let new_exam_id = crate::exams::insert(
3522            tx.as_mut(),
3523            PKeyPolicy::Generate,
3524            &NewExam {
3525                name: "name".to_string(),
3526                starts_at: None,
3527                ends_at: None,
3528                time_minutes: 120,
3529                organization_id: org,
3530                minimum_points_treshold: 24,
3531                grade_manually: false,
3532            },
3533        )
3534        .await
3535        .unwrap();
3536        let page = crate::pages::insert_page(
3537            tx.as_mut(),
3538            NewPage {
3539                exercises: vec![],
3540                exercise_slides: vec![],
3541                exercise_tasks: vec![],
3542                content: vec![],
3543                url_path: "url".to_string(),
3544                title: "title".to_string(),
3545                course_id: None,
3546                exam_id: Some(new_exam_id),
3547                chapter_id: None,
3548                front_page_of_chapter_id: None,
3549                content_search_language: None,
3550            },
3551            user,
3552            |_, _, _| unimplemented!(),
3553            |_| unimplemented!(),
3554        )
3555        .await
3556        .unwrap();
3557        let exam_page_org = get_organization_id(tx.as_mut(), page.id).await.unwrap();
3558        assert_eq!(org, exam_page_org);
3559    }
3560
3561    #[tokio::test]
3562    async fn page_update_validation_works() {
3563        let e1 = CmsPageExercise {
3564            id: Uuid::parse_str("0c9dca80-5904-4d35-a945-8c080446f667").unwrap(),
3565            name: "".to_string(),
3566            order_number: 1,
3567            score_maximum: 1,
3568            max_tries_per_slide: None,
3569            limit_number_of_tries: false,
3570            deadline: Some(Utc.with_ymd_and_hms(2125, 1, 1, 23, 59, 59).unwrap()),
3571            needs_peer_review: false,
3572            needs_self_review: false,
3573            peer_or_self_review_config: None,
3574            peer_or_self_review_questions: None,
3575            use_course_default_peer_or_self_review_config: false,
3576        };
3577        let e1_s1 = CmsPageExerciseSlide {
3578            id: Uuid::parse_str("43380e81-6ff2-4f46-9f38-af0ac6a8421a").unwrap(),
3579            exercise_id: e1.id,
3580            order_number: 1,
3581        };
3582        let e1_s1_t1 = CmsPageExerciseTask {
3583            id: Uuid::parse_str("6fb19c22-bca0-42cf-8be5-4141e21cc7a9").unwrap(),
3584            exercise_slide_id: e1_s1.id,
3585            assignment: serde_json::json!([]),
3586            exercise_type: "exercise".to_string(),
3587            private_spec: None,
3588            order_number: 1,
3589        };
3590
3591        // Works without exercises
3592        assert!(
3593            create_update(vec![], vec![], vec![])
3594                .validate_exercise_data()
3595                .is_ok()
3596        );
3597
3598        // Works with single valid exercise
3599        assert!(
3600            create_update(
3601                vec![e1.clone()],
3602                vec![e1_s1.clone()],
3603                vec![e1_s1_t1.clone()],
3604            )
3605            .validate_exercise_data()
3606            .is_ok()
3607        );
3608
3609        // Fails with missing slide
3610        assert!(
3611            create_update(vec![e1.clone()], vec![], vec![e1_s1_t1],)
3612                .validate_exercise_data()
3613                .is_err()
3614        );
3615
3616        // Fails with missing task
3617        assert!(
3618            create_update(vec![e1], vec![e1_s1], vec![],)
3619                .validate_exercise_data()
3620                .is_err()
3621        );
3622    }
3623
3624    fn create_update(
3625        exercises: Vec<CmsPageExercise>,
3626        exercise_slides: Vec<CmsPageExerciseSlide>,
3627        exercise_tasks: Vec<CmsPageExerciseTask>,
3628    ) -> CmsPageUpdate {
3629        CmsPageUpdate {
3630            content: vec![],
3631            exercises,
3632            exercise_slides,
3633            exercise_tasks,
3634            url_path: "".to_string(),
3635            title: "".to_string(),
3636            chapter_id: None,
3637        }
3638    }
3639
3640    #[tokio::test]
3641    async fn page_upsert_peer_reviews_work() {
3642        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
3643        let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
3644        let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
3645        let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
3646            .await
3647            .unwrap();
3648
3649        let pr1 = CmsPeerOrSelfReviewConfig {
3650            id:pr_id,
3651            exercise_id: Some(exercise_id),
3652            course_id: course,
3653            processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
3654            accepting_threshold: 0.5,
3655            peer_reviews_to_give: 2,
3656            peer_reviews_to_receive: 1,
3657            points_are_all_or_nothing: false,
3658            reset_answer_if_zero_points_from_review: false,
3659            review_instructions: None,
3660        };
3661        let prq = CmsPeerOrSelfReviewQuestion {
3662            id: prq_id,
3663            peer_or_self_review_config_id: pr_id,
3664            answer_required: true,
3665            order_number: 0,
3666            question: "juu".to_string(),
3667            question_type:
3668                crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
3669            weight: 0.31,
3670        };
3671        let mut remapped_exercises = HashMap::new();
3672        remapped_exercises.insert(exercise_id, exercise);
3673        let pr_res = upsert_peer_or_self_review_configs(
3674            tx.as_mut(),
3675            &[],
3676            &[pr1],
3677            &remapped_exercises,
3678            false,
3679        )
3680        .await
3681        .unwrap();
3682        let prq_res =
3683            upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, false)
3684                .await
3685                .unwrap();
3686
3687        assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
3688
3689        assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
3690        assert!(!pr_res.get(&pr_id).unwrap().points_are_all_or_nothing);
3691        assert_eq!(prq_res.get(&prq_id).unwrap().weight, 0.31);
3692    }
3693
3694    #[tokio::test]
3695    async fn page_upsert_peer_reviews_work_retain_ids() {
3696        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
3697        let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
3698            .await
3699            .unwrap();
3700        let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
3701        let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
3702        let pr1 = CmsPeerOrSelfReviewConfig {
3703            id:pr_id,
3704            exercise_id: Some(exercise_id),
3705            course_id: course,
3706            processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
3707            accepting_threshold: 0.5,
3708            peer_reviews_to_give: 2,
3709            peer_reviews_to_receive: 1,
3710            points_are_all_or_nothing: true,
3711            reset_answer_if_zero_points_from_review: false,
3712            review_instructions: None,
3713        };
3714        let prq = CmsPeerOrSelfReviewQuestion {
3715            id: prq_id,
3716            peer_or_self_review_config_id: pr_id,
3717            answer_required: true,
3718            order_number: 0,
3719            question: "juu".to_string(),
3720            question_type:
3721                crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
3722            weight: 0.0,
3723        };
3724        let mut remapped_exercises = HashMap::new();
3725        remapped_exercises.insert(exercise_id, exercise);
3726        let pr_res =
3727            upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[pr1], &remapped_exercises, true)
3728                .await
3729                .unwrap();
3730        let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, true)
3731            .await
3732            .unwrap();
3733
3734        assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
3735        assert!(pr_res.get(&pr_id).unwrap().id == pr_id);
3736
3737        assert!(prq_res.get(&prq_id).unwrap().id == prq_id);
3738        assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
3739    }
3740
3741    #[tokio::test]
3742    async fn page_upsert_peer_reviews_work_empty() {
3743        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
3744        let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
3745            .await
3746            .unwrap();
3747        let mut remapped_exercises = HashMap::new();
3748        remapped_exercises.insert(exercise_id, exercise);
3749        let pr_res =
3750            upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[], &remapped_exercises, true)
3751                .await
3752                .unwrap();
3753        let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[], &pr_res, true)
3754            .await
3755            .unwrap();
3756
3757        assert!(pr_res.is_empty());
3758        assert!(prq_res.is_empty());
3759    }
3760
3761    #[tokio::test]
3762    async fn reorder_top_level_pages_works() {
3763        insert_data!(:tx, :user, :org, :course);
3764
3765        // First, delete any existing pages in this course to ensure a clean slate
3766        let existing_pages =
3767            get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
3768                .await
3769                .unwrap();
3770        for page in &existing_pages {
3771            delete_page_and_exercises(tx.as_mut(), page.id, user)
3772                .await
3773                .unwrap();
3774        }
3775
3776        // Create our test pages
3777        let page1 = NewCoursePage::new(course, 0, "top-page-1", "Top Page 1");
3778        let (page1_id, _) = insert_course_page(tx.as_mut(), &page1, user).await.unwrap();
3779        let page2 = NewCoursePage::new(course, 1, "top-page-2", "Top Page 2");
3780        let (page2_id, _) = insert_course_page(tx.as_mut(), &page2, user).await.unwrap();
3781        let page3 = NewCoursePage::new(course, 2, "top-page-3", "Top Page 3");
3782        let (page3_id, _) = insert_course_page(tx.as_mut(), &page3, user).await.unwrap();
3783
3784        let mut pages =
3785            get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
3786                .await
3787                .unwrap();
3788
3789        let page1_index = pages.iter().position(|p| p.id == page1_id).unwrap();
3790        let page2_index = pages.iter().position(|p| p.id == page2_id).unwrap();
3791        let page3_index = pages.iter().position(|p| p.id == page3_id).unwrap();
3792
3793        pages[page1_index].order_number = 2;
3794        pages[page3_index].order_number = 1;
3795        pages[page2_index].order_number = 3;
3796
3797        // Apply the reordering
3798        reorder_pages(tx.as_mut(), &pages, course).await.unwrap();
3799
3800        // Check that the reordering took effect
3801        let page1_updated = get_page(tx.as_mut(), page1_id).await.unwrap();
3802        let page2_updated = get_page(tx.as_mut(), page2_id).await.unwrap();
3803        let page3_updated = get_page(tx.as_mut(), page3_id).await.unwrap();
3804
3805        assert_eq!(page1_updated.order_number, 2);
3806        assert_eq!(page2_updated.order_number, 3);
3807        assert_eq!(page3_updated.order_number, 1);
3808    }
3809}