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