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