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