headless_lms_models/
pages.rs

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