headless_lms_models/
pages.rs

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