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