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