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