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 pub teacher_reviews_answer_after_locking: bool,
1003}
1004
1005impl CmsPageExercise {
1006 fn from_exercise_and_peer_review_data(
1007 exercise: Exercise,
1008 peer_or_self_review_config: Option<CmsPeerOrSelfReviewConfig>,
1009 peer_or_self_review_questions: Option<Vec<CmsPeerOrSelfReviewQuestion>>,
1010 ) -> Self {
1011 Self {
1012 id: exercise.id,
1013 name: exercise.name,
1014 order_number: exercise.order_number,
1015 score_maximum: exercise.score_maximum,
1016 max_tries_per_slide: exercise.max_tries_per_slide,
1017 limit_number_of_tries: exercise.limit_number_of_tries,
1018 deadline: exercise.deadline,
1019 needs_peer_review: exercise.needs_peer_review,
1020 needs_self_review: exercise.needs_self_review,
1021 use_course_default_peer_or_self_review_config: exercise
1022 .use_course_default_peer_or_self_review_config,
1023 teacher_reviews_answer_after_locking: exercise.teacher_reviews_answer_after_locking,
1024 peer_or_self_review_config,
1025 peer_or_self_review_questions,
1026 }
1027 }
1028}
1029
1030#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
1031#[cfg_attr(feature = "ts_rs", derive(TS))]
1032pub struct CmsPageExerciseSlide {
1033 pub id: Uuid,
1034 pub exercise_id: Uuid,
1035 pub order_number: i32,
1036}
1037
1038impl From<ExerciseSlide> for CmsPageExerciseSlide {
1039 fn from(slide: ExerciseSlide) -> Self {
1040 Self {
1041 id: slide.id,
1042 exercise_id: slide.exercise_id,
1043 order_number: slide.order_number,
1044 }
1045 }
1046}
1047
1048#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
1049#[cfg_attr(feature = "ts_rs", derive(TS))]
1050pub struct CmsPageExerciseTask {
1051 pub id: Uuid,
1052 pub exercise_slide_id: Uuid,
1053 pub assignment: serde_json::Value,
1054 pub exercise_type: String,
1055 pub private_spec: Option<serde_json::Value>,
1056 pub order_number: i32,
1057}
1058
1059impl From<ExerciseTask> for CmsPageExerciseTask {
1060 fn from(task: ExerciseTask) -> Self {
1061 CmsPageExerciseTask {
1062 id: task.id,
1063 exercise_slide_id: task.exercise_slide_id,
1064 assignment: task.assignment,
1065 exercise_type: task.exercise_type,
1066 private_spec: task.private_spec,
1067 order_number: task.order_number,
1068 }
1069 }
1070}
1071
1072#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
1073#[cfg_attr(feature = "ts_rs", derive(TS))]
1074pub struct CmsPageUpdate {
1075 pub content: Vec<GutenbergBlock>,
1076 pub exercises: Vec<CmsPageExercise>,
1077 pub exercise_slides: Vec<CmsPageExerciseSlide>,
1078 pub exercise_tasks: Vec<CmsPageExerciseTask>,
1079 pub url_path: String,
1080 pub title: String,
1081 pub chapter_id: Option<Uuid>,
1082}
1083
1084impl CmsPageUpdate {
1085 pub fn validate_exercise_data(&self) -> ModelResult<()> {
1087 let mut exercise_ids: HashMap<Uuid, bool> =
1088 self.exercises.iter().map(|x| (x.id, false)).collect();
1089 let mut slide_ids = self
1090 .exercise_slides
1091 .iter()
1092 .map(|x| {
1093 if let hash_map::Entry::Occupied(mut e) = exercise_ids.entry(x.exercise_id) {
1094 e.insert(true);
1095 Ok((x.id, false))
1096 } else {
1097 Err(ModelError::new(
1098 ModelErrorType::PreconditionFailed,
1099 "Exercide ids in slides don't match.".to_string(),
1100 None,
1101 ))
1102 }
1103 })
1104 .collect::<ModelResult<HashMap<Uuid, bool>>>()?;
1105
1106 if let Some((exercise_id, _)) = exercise_ids.into_iter().find(|(_, x)| !x) {
1107 return Err(ModelError::new(
1108 ModelErrorType::PreconditionFailedWithCMSAnchorBlockId {
1109 id: exercise_id,
1110 description: "Exercise must have at least one slide.",
1111 },
1112 "Exercise must have at least one slide.".to_string(),
1113 None,
1114 ));
1115 }
1116
1117 for task in self.exercise_tasks.iter() {
1118 if let hash_map::Entry::Occupied(mut e) = slide_ids.entry(task.exercise_slide_id) {
1119 e.insert(true);
1120 } else {
1121 return Err(ModelError::new(
1122 ModelErrorType::PreconditionFailed,
1123 "Exercise slide ids in tasks don't match.".to_string(),
1124 None,
1125 ));
1126 }
1127 }
1128 if let Some((slide_id, _)) = slide_ids.into_iter().find(|(_, x)| !x) {
1129 return Err(ModelError::new(
1130 ModelErrorType::PreconditionFailedWithCMSAnchorBlockId {
1131 id: slide_id,
1132 description: "Exercise slide must have at least one task.",
1133 },
1134 "Exercise slide must have at least one task.".to_string(),
1135 None,
1136 ));
1137 }
1138 Ok(())
1139 }
1140}
1141
1142#[derive(Debug, Clone)]
1143pub struct PageUpdateArgs {
1144 pub page_id: Uuid,
1145 pub author: Uuid,
1146 pub cms_page_update: CmsPageUpdate,
1147 pub retain_ids: bool,
1148 pub history_change_reason: HistoryChangeReason,
1149 pub is_exam_page: bool,
1150}
1151
1152pub async fn update_page(
1153 conn: &mut PgConnection,
1154 page_update: PageUpdateArgs,
1155 spec_fetcher: impl SpecFetcher,
1156 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
1157) -> ModelResult<ContentManagementPage> {
1158 let mut cms_page_update = page_update.cms_page_update;
1159 cms_page_update.validate_exercise_data()?;
1160
1161 for exercise in cms_page_update.exercises.iter_mut() {
1162 if let Some(peer_or_self_review_questions) = exercise.peer_or_self_review_questions.as_mut()
1163 {
1164 normalize_cms_peer_or_self_review_questions(peer_or_self_review_questions);
1165 }
1166 }
1167
1168 let content = replace_duplicate_client_ids(cms_page_update.content.clone());
1169
1170 if !page_update.is_exam_page
1171 && cms_page_update.chapter_id.is_none()
1172 && contains_blocks_not_allowed_in_top_level_pages(&content)
1173 {
1174 return Err(ModelError::new(
1175 ModelErrorType::Generic , "Top level non-exam pages cannot contain exercises, exercise tasks or list of exercises in the chapter".to_string(), None
1176 ));
1177 }
1178
1179 let mut tx = conn.begin().await?;
1180
1181 let page = sqlx::query_as!(
1183 Page,
1184 r"
1185UPDATE pages
1186SET content = $2,
1187 url_path = $3,
1188 title = $4,
1189 chapter_id = $5
1190WHERE id = $1
1191RETURNING id,
1192 created_at,
1193 updated_at,
1194 course_id,
1195 exam_id,
1196 chapter_id,
1197 url_path,
1198 title,
1199 deleted_at,
1200 content,
1201 order_number,
1202 copied_from,
1203 pages.hidden,
1204 pages.page_language_group_id
1205 ",
1206 page_update.page_id,
1207 serde_json::to_value(&content)?,
1208 cms_page_update.url_path.trim(),
1209 cms_page_update.title.trim(),
1210 cms_page_update.chapter_id
1211 )
1212 .fetch_one(&mut *tx)
1213 .await?;
1214
1215 let existing_exercise_ids =
1217 crate::exercises::delete_exercises_by_page_id(&mut tx, page.id).await?;
1218 let remapped_exercises = upsert_exercises(
1219 &mut tx,
1220 &page,
1221 &existing_exercise_ids,
1222 &cms_page_update.exercises,
1223 page_update.retain_ids,
1224 )
1225 .await?;
1226
1227 let existing_exercise_slide_ids =
1229 crate::exercise_slides::delete_exercise_slides_by_exercise_ids(
1230 &mut tx,
1231 &existing_exercise_ids,
1232 )
1233 .await?;
1234 let remapped_exercise_slides = upsert_exercise_slides(
1235 &mut tx,
1236 &remapped_exercises,
1237 &existing_exercise_slide_ids,
1238 &cms_page_update.exercise_slides,
1239 page_update.retain_ids,
1240 )
1241 .await?;
1242
1243 let existing_peer_or_self_review_config_ids =
1245 crate::peer_or_self_review_configs::delete_peer_reviews_by_exrcise_ids(
1246 &mut tx,
1247 &existing_exercise_ids,
1248 )
1249 .await?;
1250
1251 let (peer_or_self_review_configs, peer_or_self_review_questions) = cms_page_update
1252 .exercises
1253 .into_iter()
1254 .filter(|e| !e.use_course_default_peer_or_self_review_config)
1255 .flat_map(|e| {
1256 e.peer_or_self_review_config
1257 .zip(e.peer_or_self_review_questions)
1258 })
1259 .fold((vec![], vec![]), |(mut a, mut b), (pr, prq)| {
1260 a.push(pr);
1261 b.extend(prq);
1262 (a, b)
1263 });
1264
1265 let remapped_peer_or_self_review_configs = upsert_peer_or_self_review_configs(
1266 &mut tx,
1267 &existing_peer_or_self_review_config_ids,
1268 &peer_or_self_review_configs,
1269 &remapped_exercises,
1270 page_update.retain_ids,
1271 )
1272 .await?;
1273
1274 let existing_peer_or_self_review_questions =
1276 crate::peer_or_self_review_questions::delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
1277 &mut tx,
1278 &existing_peer_or_self_review_config_ids,
1279 )
1280 .await?;
1281
1282 let remapped_peer_or_self_review_questions = upsert_peer_or_self_review_questions(
1283 &mut tx,
1284 &existing_peer_or_self_review_questions,
1285 &peer_or_self_review_questions,
1286 &remapped_peer_or_self_review_configs,
1287 page_update.retain_ids,
1288 )
1289 .await?;
1290
1291 let existing_exercise_task_specs = sqlx::query_as!(
1293 ExerciseTaskIdAndSpec,
1294 "
1295UPDATE exercise_tasks
1296SET deleted_at = now()
1297WHERE exercise_slide_id = ANY($1)
1298AND deleted_at IS NULL
1299RETURNING id,
1300 private_spec,
1301 public_spec,
1302 model_solution_spec;
1303 ",
1304 &existing_exercise_slide_ids,
1305 )
1306 .fetch_all(&mut *tx)
1307 .await?;
1308 let final_tasks = upsert_exercise_tasks(
1309 &mut tx,
1310 &remapped_exercise_slides,
1311 &existing_exercise_task_specs,
1312 &cms_page_update.exercise_tasks,
1313 page_update.retain_ids,
1314 &spec_fetcher,
1315 fetch_service_info,
1316 )
1317 .await?;
1318
1319 let new_content = headless_lms_utils::document_schema_processor::remap_ids_in_content(
1321 &page.content,
1322 remapped_exercises
1323 .iter()
1324 .map(|(id, e)| (*id, e.id))
1325 .collect::<HashMap<Uuid, Uuid>>(),
1326 )?;
1327
1328 let page = sqlx::query_as!(
1329 Page,
1330 "
1331UPDATE pages
1332SET content = $1
1333WHERE id = $2
1334RETURNING id,
1335 created_at,
1336 updated_at,
1337 course_id,
1338 exam_id,
1339 chapter_id,
1340 url_path,
1341 title,
1342 deleted_at,
1343 content,
1344 order_number,
1345 copied_from,
1346 hidden,
1347 page_language_group_id
1348 ",
1349 new_content,
1350 page.id
1351 )
1352 .fetch_one(&mut *tx)
1353 .await?;
1354
1355 let x = remapped_exercises.into_values().collect::<Vec<_>>();
1356 let final_exercises = x
1357 .iter()
1358 .map(|e| {
1359 let peer_or_self_review_config = remapped_peer_or_self_review_configs
1360 .values()
1361 .find(|prc| prc.exercise_id == Some(e.id));
1362 if let Some(prc) = peer_or_self_review_config {
1363 let peer_or_self_review_questions = remapped_peer_or_self_review_questions
1364 .values()
1365 .filter(|prq| prq.peer_or_self_review_config_id == prc.id)
1366 .cloned()
1367 .collect::<Vec<_>>();
1368 return CmsPageExercise::from_exercise_and_peer_review_data(
1369 e.clone(),
1370 Some(prc.clone()),
1371 Some(peer_or_self_review_questions),
1372 );
1373 }
1374 CmsPageExercise::from_exercise_and_peer_review_data(e.clone(), None, None)
1375 })
1376 .collect();
1377 let final_slides: Vec<CmsPageExerciseSlide> = remapped_exercise_slides.into_values().collect();
1378 let final_peer_reviews: Vec<CmsPeerOrSelfReviewConfig> =
1379 remapped_peer_or_self_review_configs.into_values().collect();
1380 let final_peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion> =
1381 remapped_peer_or_self_review_questions
1382 .into_values()
1383 .collect();
1384 let history_content = PageHistoryContent {
1385 content: page.content.clone(),
1386 exercises: final_exercises,
1387 exercise_slides: final_slides,
1388 exercise_tasks: final_tasks,
1389 peer_or_self_review_configs: final_peer_reviews,
1390 peer_or_self_review_questions: final_peer_or_self_review_questions,
1391 };
1392 crate::page_history::insert(
1393 &mut tx,
1394 PKeyPolicy::Generate,
1395 page_update.page_id,
1396 &cms_page_update.title,
1397 &history_content,
1398 page_update.history_change_reason,
1399 page_update.author,
1400 None,
1401 )
1402 .await?;
1403 let organization_id = get_organization_id(&mut tx, page.id).await?;
1404
1405 tx.commit().await?;
1406
1407 Ok(ContentManagementPage {
1408 page,
1409 exercises: history_content.exercises,
1410 exercise_slides: history_content.exercise_slides,
1411 exercise_tasks: history_content.exercise_tasks,
1412 peer_or_self_review_configs: history_content.peer_or_self_review_configs,
1413 peer_or_self_review_questions: history_content.peer_or_self_review_questions,
1414 organization_id,
1415 })
1416}
1417
1418async fn upsert_exercises(
1420 conn: &mut PgConnection,
1421 page: &Page,
1422 existing_exercise_ids: &[Uuid],
1423 exercise_updates: &[CmsPageExercise],
1424 retain_exercise_ids: bool,
1425) -> ModelResult<HashMap<Uuid, Exercise>> {
1426 let mut remapped_exercises = HashMap::new();
1427 for exercise_update in exercise_updates.iter() {
1428 let exercise_exists = existing_exercise_ids.contains(&exercise_update.id);
1429 let safe_for_db_exercise_id = if retain_exercise_ids || exercise_exists {
1430 exercise_update.id
1431 } else {
1432 Uuid::new_v4()
1433 };
1434
1435 let db_exercise = crate::exercises::get_by_id(&mut *conn, safe_for_db_exercise_id)
1437 .await
1438 .optional()?;
1439
1440 let mut exercise_language_group_id = None;
1441
1442 if let Some(db_exercise) = db_exercise {
1443 exercise_language_group_id = db_exercise.exercise_language_group_id;
1444 }
1445 if let Some(course_id) = page.course_id {
1446 let course = crate::courses::get_course(&mut *conn, course_id).await?;
1447
1448 exercise_language_group_id = Some(
1449 crate::exercise_language_groups::insert(
1450 &mut *conn,
1451 PKeyPolicy::Generate,
1452 course.course_language_group_id,
1453 )
1454 .await?,
1455 );
1456 }
1457
1458 let exercise = sqlx::query_as!(
1459 Exercise,
1460 "
1461INSERT INTO exercises(
1462 id,
1463 course_id,
1464 name,
1465 order_number,
1466 page_id,
1467 chapter_id,
1468 exam_id,
1469 score_maximum,
1470 max_tries_per_slide,
1471 limit_number_of_tries,
1472 deadline,
1473 needs_peer_review,
1474 needs_self_review,
1475 use_course_default_peer_or_self_review_config,
1476 exercise_language_group_id,
1477 teacher_reviews_answer_after_locking
1478 )
1479VALUES (
1480 $1,
1481 $2,
1482 $3,
1483 $4,
1484 $5,
1485 $6,
1486 $7,
1487 $8,
1488 $9,
1489 $10,
1490 $11,
1491 $12,
1492 $13,
1493 $14,
1494 $15,
1495 $16
1496 ) ON CONFLICT (id) DO
1497UPDATE
1498SET course_id = $2,
1499 name = $3,
1500 order_number = $4,
1501 page_id = $5,
1502 chapter_id = $6,
1503 exam_id = $7,
1504 score_maximum = $8,
1505 max_tries_per_slide = $9,
1506 limit_number_of_tries = $10,
1507 deadline = $11,
1508 needs_peer_review = $12,
1509 needs_self_review = $13,
1510 use_course_default_peer_or_self_review_config = $14,
1511 exercise_language_group_id = $15,
1512 teacher_reviews_answer_after_locking = $16,
1513 deleted_at = NULL
1514RETURNING *;
1515 ",
1516 safe_for_db_exercise_id,
1517 page.course_id,
1518 exercise_update.name,
1519 exercise_update.order_number,
1520 page.id,
1521 page.chapter_id,
1522 page.exam_id,
1523 exercise_update.score_maximum,
1524 exercise_update.max_tries_per_slide,
1525 exercise_update.limit_number_of_tries,
1526 exercise_update.deadline,
1527 exercise_update.needs_peer_review,
1528 exercise_update.needs_self_review,
1529 exercise_update.use_course_default_peer_or_self_review_config,
1530 exercise_language_group_id,
1531 exercise_update.teacher_reviews_answer_after_locking,
1532 )
1533 .fetch_one(&mut *conn)
1534 .await?;
1535
1536 remapped_exercises.insert(exercise_update.id, exercise);
1537 }
1538 Ok(remapped_exercises)
1539}
1540
1541async fn upsert_exercise_slides(
1543 conn: &mut PgConnection,
1544 remapped_exercises: &HashMap<Uuid, Exercise>,
1545 existing_slide_ids: &[Uuid],
1546 slide_updates: &[CmsPageExerciseSlide],
1547 retain_exercise_ids: bool,
1548) -> ModelResult<HashMap<Uuid, CmsPageExerciseSlide>> {
1549 let mut remapped_exercise_slides = HashMap::new();
1550 for slide_update in slide_updates.iter() {
1551 let slide_exists = existing_slide_ids.contains(&slide_update.id);
1552 let safe_for_db_slide_id = if retain_exercise_ids || slide_exists {
1553 slide_update.id
1554 } else {
1555 Uuid::new_v4()
1556 };
1557 let safe_for_db_exercise_id = remapped_exercises
1558 .get(&slide_update.exercise_id)
1559 .ok_or_else(|| {
1560 ModelError::new(
1561 ModelErrorType::InvalidRequest,
1562 "Illegal exercise id for exercise slide.".to_string(),
1563 None,
1564 )
1565 })?
1566 .id;
1567
1568 let exercise_slide = sqlx::query_as!(
1569 CmsPageExerciseSlide,
1570 "
1571INSERT INTO exercise_slides (id, exercise_id, order_number)
1572VALUES ($1, $2, $3) ON CONFLICT (id) DO
1573UPDATE
1574SET exercise_id = $2,
1575 order_number = $3,
1576 deleted_at = NULL
1577RETURNING id,
1578 exercise_id,
1579 order_number;
1580 ",
1581 safe_for_db_slide_id,
1582 safe_for_db_exercise_id,
1583 slide_update.order_number,
1584 )
1585 .fetch_one(&mut *conn)
1586 .await?;
1587
1588 remapped_exercise_slides.insert(slide_update.id, exercise_slide);
1589 }
1590 Ok(remapped_exercise_slides)
1591}
1592
1593async fn upsert_exercise_tasks(
1595 conn: &mut PgConnection,
1596 remapped_slides: &HashMap<Uuid, CmsPageExerciseSlide>,
1597 existing_task_specs: &[ExerciseTaskIdAndSpec],
1598 task_updates: &[CmsPageExerciseTask],
1599 retain_exercise_ids: bool,
1600 spec_fetcher: impl SpecFetcher,
1601 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
1602) -> ModelResult<Vec<CmsPageExerciseTask>> {
1603 let exercise_types: Vec<String> = task_updates
1605 .iter()
1606 .map(|task| task.exercise_type.clone())
1607 .unique()
1608 .collect();
1609 let exercise_service_hashmap = exercise_service_info::get_selected_exercise_services_by_type(
1610 &mut *conn,
1611 &exercise_types,
1612 fetch_service_info,
1613 )
1614 .await?;
1615 let public_spec_urls_by_exercise_type = exercise_service_hashmap
1616 .iter()
1617 .map(|(key, (service, info))| Ok((key, get_internal_public_spec_url(service, info)?)))
1618 .collect::<ModelResult<HashMap<&String, Url>>>()?;
1619 let model_solution_urls_by_exercise_type = exercise_service_hashmap
1620 .iter()
1621 .map(|(key, (service, info))| Ok((key, get_model_solution_url(service, info)?)))
1622 .collect::<ModelResult<HashMap<&String, Url>>>()?;
1623
1624 let mut remapped_exercise_tasks = Vec::new();
1625 for task_update in task_updates.iter() {
1626 let existing_exercise_task = existing_task_specs.iter().find(|o| o.id == task_update.id);
1627 let safe_for_db_exercise_task_id = match existing_exercise_task {
1628 Some(_) => task_update.id,
1629 _ if retain_exercise_ids => task_update.id,
1630 None => Uuid::new_v4(),
1631 };
1632
1633 let task_exists = existing_task_specs
1634 .iter()
1635 .any(|task| task.id == task_update.id);
1636 let safe_for_db_task_id = if retain_exercise_ids || task_exists {
1637 task_update.id
1638 } else {
1639 Uuid::new_v4()
1640 };
1641 let normalized_task = NormalizedCmsExerciseTask {
1642 id: safe_for_db_task_id,
1643 assignment: task_update.assignment.clone(),
1644 exercise_type: task_update.exercise_type.clone(),
1645 private_spec: task_update.private_spec.clone(),
1646 };
1647 let model_solution_spec = fetch_derived_spec(
1648 existing_exercise_task,
1649 &normalized_task,
1650 &model_solution_urls_by_exercise_type,
1651 &spec_fetcher,
1652 existing_exercise_task.and_then(|value| value.model_solution_spec.clone()),
1653 task_update.id,
1654 )
1655 .await?;
1656 let public_spec: Option<serde_json::Value> = fetch_derived_spec(
1657 existing_exercise_task,
1658 &normalized_task,
1659 &public_spec_urls_by_exercise_type,
1660 &spec_fetcher,
1661 existing_exercise_task.and_then(|value| value.public_spec.clone()),
1662 task_update.id,
1663 )
1664 .await?;
1665 let safe_for_db_exercise_slide_id = remapped_slides
1666 .get(&task_update.exercise_slide_id)
1667 .ok_or_else(|| {
1668 ModelError::new(
1669 ModelErrorType::InvalidRequest,
1670 "Illegal exercise slide id for exercise task.".to_string(),
1671 None,
1672 )
1673 })?
1674 .id;
1675
1676 let exercise_task = sqlx::query_as!(
1678 CmsPageExerciseTask,
1679 "
1680INSERT INTO exercise_tasks(
1681 id,
1682 exercise_slide_id,
1683 exercise_type,
1684 assignment,
1685 public_spec,
1686 private_spec,
1687 model_solution_spec,
1688 order_number
1689 )
1690VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO
1691UPDATE
1692SET exercise_slide_id = $2,
1693 exercise_type = $3,
1694 assignment = $4,
1695 public_spec = $5,
1696 private_spec = $6,
1697 model_solution_spec = $7,
1698 order_number = $8,
1699 deleted_at = NULL
1700RETURNING id,
1701 exercise_slide_id,
1702 assignment,
1703 exercise_type,
1704 private_spec,
1705 order_number
1706 ",
1707 safe_for_db_exercise_task_id,
1708 safe_for_db_exercise_slide_id,
1709 task_update.exercise_type,
1710 task_update.assignment,
1711 public_spec,
1712 task_update.private_spec,
1713 model_solution_spec,
1714 task_update.order_number,
1715 )
1716 .fetch_one(&mut *conn)
1717 .await?;
1718 remapped_exercise_tasks.push(exercise_task)
1719 }
1720 Ok(remapped_exercise_tasks)
1721}
1722
1723pub async fn upsert_peer_or_self_review_configs(
1724 conn: &mut PgConnection,
1725 existing_peer_reviews: &[Uuid],
1726 peer_reviews: &[CmsPeerOrSelfReviewConfig],
1727 remapped_exercises: &HashMap<Uuid, Exercise>,
1728 retain_ids: bool,
1729) -> ModelResult<HashMap<Uuid, CmsPeerOrSelfReviewConfig>> {
1730 if peer_reviews.is_empty() {
1731 Ok(HashMap::new())
1732 } else {
1733 let mut new_peer_or_self_review_config_id_to_old_id = HashMap::new();
1734
1735 let mut sql: QueryBuilder<Postgres> = QueryBuilder::new(
1736 "INSERT INTO peer_or_self_review_configs (
1737 id,
1738 course_id,
1739 exercise_id,
1740 peer_reviews_to_give,
1741 peer_reviews_to_receive,
1742 processing_strategy,
1743 accepting_threshold,
1744 points_are_all_or_nothing,
1745 reset_answer_if_zero_points_from_review,
1746 review_instructions,
1747 deleted_at
1748 ) ",
1749 );
1750
1751 let mut illegal_exercise_id = None;
1753
1754 sql.push_values(peer_reviews.iter().take(1000), |mut x, pr| {
1755 let peer_review_exists = existing_peer_reviews.contains(&pr.id);
1756 let safe_for_db_peer_or_self_review_config_id = if retain_ids || peer_review_exists {
1757 pr.id
1758 } else {
1759 Uuid::new_v4()
1760 };
1761 new_peer_or_self_review_config_id_to_old_id
1762 .insert(safe_for_db_peer_or_self_review_config_id, pr.id);
1763
1764 let safe_for_db_exercise_id = pr.exercise_id.and_then(|id| {
1765 let res = remapped_exercises.get(&id).map(|e| e.id);
1766 if res.is_none() {
1767 error!("Illegal exercise id {:?}", id);
1768 illegal_exercise_id = Some(id);
1769 }
1770 res
1771 });
1772
1773 x.push_bind(safe_for_db_peer_or_self_review_config_id)
1774 .push_bind(pr.course_id)
1775 .push_bind(safe_for_db_exercise_id)
1776 .push_bind(pr.peer_reviews_to_give)
1777 .push_bind(pr.peer_reviews_to_receive)
1778 .push_bind(pr.processing_strategy)
1779 .push_bind(pr.accepting_threshold)
1780 .push_bind(pr.points_are_all_or_nothing)
1781 .push_bind(pr.reset_answer_if_zero_points_from_review)
1782 .push_bind(pr.review_instructions.clone())
1783 .push("NULL");
1784 });
1785
1786 if let Some(illegal_exercise_id) = illegal_exercise_id {
1787 return Err(ModelError::new(
1788 ModelErrorType::InvalidRequest,
1789 format!("Illegal exercise id {:?}", illegal_exercise_id),
1790 None,
1791 ));
1792 }
1793
1794 sql.push(
1795 " ON CONFLICT (id) DO
1796UPDATE
1797SET course_id = excluded.course_id,
1798 exercise_id = excluded.exercise_id,
1799 peer_reviews_to_give = excluded.peer_reviews_to_give,
1800 peer_reviews_to_receive = excluded.peer_reviews_to_receive,
1801 processing_strategy = excluded.processing_strategy,
1802 accepting_threshold = excluded.accepting_threshold,
1803 points_are_all_or_nothing = excluded.points_are_all_or_nothing,
1804 review_instructions = excluded.review_instructions,
1805 deleted_at = NULL
1806RETURNING id;
1807",
1808 );
1809
1810 let ids = sql
1811 .build()
1812 .fetch_all(&mut *conn)
1813 .await?
1814 .iter()
1815 .map(|x| x.get(0))
1816 .collect::<Vec<_>>();
1817
1818 let prs = sqlx::query_as!(
1819 CmsPeerOrSelfReviewConfig,
1820 r#"
1821SELECT id as "id!",
1822 course_id as "course_id!",
1823 exercise_id,
1824 peer_reviews_to_give as "peer_reviews_to_give!",
1825 peer_reviews_to_receive as "peer_reviews_to_receive!",
1826 processing_strategy AS "processing_strategy!: _",
1827 accepting_threshold "accepting_threshold!",
1828 points_are_all_or_nothing "points_are_all_or_nothing!",
1829 reset_answer_if_zero_points_from_review,
1830 review_instructions
1831FROM peer_or_self_review_configs
1832WHERE id IN (
1833 SELECT UNNEST($1::uuid [])
1834 )
1835 AND deleted_at IS NULL;
1836 "#,
1837 &ids
1838 )
1839 .fetch_all(&mut *conn)
1840 .await?;
1841
1842 let mut remapped_peer_reviews = HashMap::new();
1843
1844 for pr in prs {
1845 let old_id = new_peer_or_self_review_config_id_to_old_id
1846 .get(&pr.id)
1847 .ok_or_else(|| {
1848 ModelError::new(
1849 ModelErrorType::Generic,
1850 "Inserted peer reviews not found".to_string(),
1851 None,
1852 )
1853 })?;
1854 remapped_peer_reviews.insert(*old_id, pr);
1855 }
1856
1857 Ok(remapped_peer_reviews)
1858 }
1859}
1860
1861pub async fn upsert_peer_or_self_review_questions(
1862 conn: &mut PgConnection,
1863 existing_peer_or_self_review_questions: &[Uuid],
1864 peer_or_self_review_questions: &[CmsPeerOrSelfReviewQuestion],
1865 remapped_peer_or_self_review_config_ids: &HashMap<Uuid, CmsPeerOrSelfReviewConfig>,
1866 retain_ids: bool,
1867) -> ModelResult<HashMap<Uuid, CmsPeerOrSelfReviewQuestion>> {
1868 if peer_or_self_review_questions.is_empty() {
1869 Ok(HashMap::new())
1870 } else {
1871 let mut new_peer_or_self_review_question_id_to_old_id = HashMap::new();
1872
1873 let mut sql: QueryBuilder<Postgres> = QueryBuilder::new(
1874 "INSERT INTO peer_or_self_review_questions (
1875 id,
1876 peer_or_self_review_config_id,
1877 order_number,
1878 question,
1879 question_type,
1880 answer_required,
1881 weight,
1882 deleted_at
1883 ) ",
1884 );
1885
1886 let peer_or_self_review_questions = peer_or_self_review_questions
1887 .iter()
1888 .take(1000)
1889 .map(|prq| {
1890 remapped_peer_or_self_review_config_ids
1891 .get(&prq.peer_or_self_review_config_id)
1892 .map(|r| (prq, r.id))
1893 .ok_or_else(|| {
1894 ModelError::new(
1895 ModelErrorType::Generic,
1896 "No peer review found for peer review questions".to_string(),
1897 None,
1898 )
1899 })
1900 })
1901 .collect::<Result<Vec<_>, _>>()?;
1902
1903 sql.push_values(
1904 peer_or_self_review_questions,
1905 |mut x, (prq, peer_or_self_review_config_id)| {
1906 let peer_review_question_exists =
1907 existing_peer_or_self_review_questions.contains(&prq.id);
1908 let safe_for_db_peer_or_self_review_question_id =
1909 if retain_ids || peer_review_question_exists {
1910 prq.id
1911 } else {
1912 Uuid::new_v4()
1913 };
1914 new_peer_or_self_review_question_id_to_old_id
1915 .insert(safe_for_db_peer_or_self_review_question_id, prq.id);
1916
1917 x.push_bind(safe_for_db_peer_or_self_review_question_id)
1918 .push_bind(peer_or_self_review_config_id)
1919 .push_bind(prq.order_number)
1920 .push_bind(prq.question.as_str())
1921 .push_bind(prq.question_type)
1922 .push_bind(prq.answer_required)
1923 .push_bind(prq.weight)
1924 .push("NULL");
1925 },
1926 );
1927
1928 sql.push(
1929 " ON CONFLICT (id) DO
1930UPDATE
1931SET peer_or_self_review_config_id = excluded.peer_or_self_review_config_id,
1932 order_number = excluded.order_number,
1933 question = excluded.question,
1934 question_type = excluded.question_type,
1935 answer_required = excluded.answer_required,
1936 weight = excluded.weight,
1937 deleted_at = NULL
1938RETURNING id;
1939",
1940 );
1941
1942 let ids = sql
1943 .build()
1944 .fetch_all(&mut *conn)
1945 .await?
1946 .iter()
1947 .map(|x| x.get(0))
1948 .collect::<Vec<_>>();
1949
1950 let prqs = sqlx::query_as!(
1951 CmsPeerOrSelfReviewQuestion,
1952 r#"
1953SELECT id AS "id!",
1954 answer_required AS "answer_required!",
1955 order_number AS "order_number!",
1956 peer_or_self_review_config_id AS "peer_or_self_review_config_id!",
1957 question AS "question!",
1958 question_type AS "question_type!: _",
1959 weight AS "weight!"
1960FROM peer_or_self_review_questions
1961WHERE id IN (
1962 SELECT UNNEST($1::uuid [])
1963 )
1964 AND deleted_at is null;
1965 "#,
1966 &ids
1967 )
1968 .fetch_all(&mut *conn)
1969 .await?;
1970
1971 let mut remapped_peer_or_self_review_questions = HashMap::new();
1972
1973 for prq in prqs {
1974 let old_id = new_peer_or_self_review_question_id_to_old_id
1975 .get(&prq.id)
1976 .ok_or_else(|| {
1977 ModelError::new(
1978 ModelErrorType::Generic,
1979 "Inserted peer reviews not found".to_string(),
1980 None,
1981 )
1982 })?;
1983 remapped_peer_or_self_review_questions.insert(*old_id, prq);
1984 }
1985
1986 Ok(remapped_peer_or_self_review_questions)
1987 }
1988}
1989
1990pub async fn update_page_content(
1992 conn: &mut PgConnection,
1993 page_id: Uuid,
1994 content: &serde_json::Value,
1995) -> ModelResult<()> {
1996 sqlx::query!(
1997 "
1998UPDATE pages
1999SET content = $1
2000WHERE id = $2;
2001",
2002 content,
2003 page_id
2004 )
2005 .execute(conn)
2006 .await?;
2007 Ok(())
2008}
2009
2010#[derive(Debug)]
2011struct ExerciseTaskIdAndSpec {
2012 pub id: Uuid,
2013 pub private_spec: Option<serde_json::Value>,
2014 pub public_spec: Option<serde_json::Value>,
2015 pub model_solution_spec: Option<serde_json::Value>,
2016}
2017
2018async fn fetch_derived_spec(
2019 existing_exercise_task: Option<&ExerciseTaskIdAndSpec>,
2020 task_update: &NormalizedCmsExerciseTask,
2021 urls_by_exercise_type: &HashMap<&String, Url>,
2022 spec_fetcher: impl SpecFetcher,
2023 previous_spec: Option<serde_json::Value>,
2024 cms_block_id: Uuid,
2025) -> Result<Option<serde_json::Value>, ModelError> {
2026 let result_spec: Option<serde_json::Value> = match existing_exercise_task {
2027 Some(exercise_task) if exercise_task.private_spec == task_update.private_spec => {
2028 previous_spec
2030 }
2031 _ => {
2032 let url = urls_by_exercise_type
2033 .get(&task_update.exercise_type)
2034 .ok_or_else(|| {
2035 ModelError::new(
2036 ModelErrorType::PreconditionFailedWithCMSAnchorBlockId {
2037 id: cms_block_id,
2038 description: "Missing exercise type for exercise task.",
2039 },
2040 "Missing exercise type for exercise task.".to_string(),
2041 None,
2042 )
2043 })?
2044 .clone();
2045 let res = spec_fetcher(
2046 url,
2047 &task_update.exercise_type,
2048 task_update.private_spec.as_ref(),
2049 )
2050 .await?;
2051 Some(res)
2052 }
2053 };
2054 Ok(result_spec)
2055}
2056
2057pub async fn insert_new_content_page(
2058 conn: &mut PgConnection,
2059 new_page: NewPage,
2060 user: Uuid,
2061 spec_fetcher: impl SpecFetcher,
2062 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2063) -> ModelResult<Page> {
2064 let mut tx = conn.begin().await?;
2065
2066 let course_material_content = vec![GutenbergBlock::hero_section(new_page.title.trim(), "")];
2067
2068 let content_page = NewPage {
2069 chapter_id: new_page.chapter_id,
2070 content: course_material_content,
2071 course_id: new_page.course_id,
2072 exam_id: None,
2073 front_page_of_chapter_id: None,
2074 title: new_page.title,
2075 url_path: new_page.url_path,
2076 exercises: vec![],
2077 exercise_slides: vec![],
2078 exercise_tasks: vec![],
2079 content_search_language: None,
2080 };
2081 let page = crate::pages::insert_page(
2082 &mut tx,
2083 content_page,
2084 user,
2085 spec_fetcher,
2086 fetch_service_info,
2087 )
2088 .await?;
2089
2090 tx.commit().await?;
2091 Ok(page)
2092}
2093
2094pub async fn insert_page(
2095 conn: &mut PgConnection,
2096 new_page: NewPage,
2097 author: Uuid,
2098 spec_fetcher: impl SpecFetcher,
2099 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2100) -> ModelResult<Page> {
2101 let mut page_language_group_id = None;
2102 if let Some(course_id) = new_page.course_id {
2103 let course = crate::courses::get_course(&mut *conn, course_id).await?;
2105 let new_language_group_id = crate::page_language_groups::insert(
2106 &mut *conn,
2107 crate::PKeyPolicy::Generate,
2108 course.course_language_group_id,
2109 )
2110 .await?;
2111 page_language_group_id = Some(new_language_group_id);
2112 }
2113
2114 let next_order_number = match (new_page.chapter_id, new_page.course_id) {
2115 (Some(id), _) => get_next_page_order_number_in_chapter(conn, id).await?,
2116 (None, Some(course_id)) => {
2117 get_next_order_number_for_courses_top_level_pages(conn, course_id).await?
2118 }
2119 (None, None) => 1,
2120 };
2121
2122 let course: OptionFuture<_> = new_page
2123 .course_id
2124 .map(|id| crate::courses::get_course(conn, id))
2125 .into();
2126 let course = course.await.transpose()?;
2127
2128 let content = replace_duplicate_client_ids(new_page.content.clone());
2129
2130 let mut tx = conn.begin().await?;
2131
2132 let content_search_language = course
2133 .and_then(|c| c.content_search_language)
2134 .or(new_page.content_search_language)
2135 .unwrap_or_else(|| "simple".to_string());
2136 let page = sqlx::query_as!(
2137 Page,
2138 r#"
2139INSERT INTO pages(
2140 course_id,
2141 exam_id,
2142 content,
2143 url_path,
2144 title,
2145 order_number,
2146 chapter_id,
2147 content_search_language,
2148 page_language_group_id
2149 )
2150VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)
2151RETURNING id,
2152 created_at,
2153 updated_at,
2154 course_id,
2155 exam_id,
2156 chapter_id,
2157 url_path,
2158 title,
2159 deleted_at,
2160 content,
2161 order_number,
2162 copied_from,
2163 pages.hidden,
2164 page_language_group_id
2165 "#,
2166 new_page.course_id,
2167 new_page.exam_id,
2168 serde_json::to_value(content)?,
2169 new_page.url_path.trim(),
2170 new_page.title.trim(),
2171 next_order_number,
2172 new_page.chapter_id,
2173 content_search_language as _,
2174 page_language_group_id,
2175 )
2176 .fetch_one(&mut *tx)
2177 .await?;
2178
2179 let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(page.content)?;
2180
2181 let cms_page = update_page(
2182 &mut tx,
2183 PageUpdateArgs {
2184 page_id: page.id,
2185 author,
2186 cms_page_update: CmsPageUpdate {
2187 content: parsed_content,
2188 exercises: new_page.exercises,
2189 exercise_slides: new_page.exercise_slides,
2190 exercise_tasks: new_page.exercise_tasks,
2191 url_path: page.url_path,
2192 title: page.title,
2193 chapter_id: page.chapter_id,
2194 },
2195 retain_ids: false,
2196 history_change_reason: HistoryChangeReason::PageSaved,
2197 is_exam_page: new_page.exam_id.is_some(),
2198 },
2199 spec_fetcher,
2200 fetch_service_info,
2201 )
2202 .await?;
2203
2204 if let Some(front_page_of_chapter_id) = new_page.front_page_of_chapter_id {
2205 let _res = sqlx::query_as!(
2206 DatabaseChapter,
2207 r#"
2208UPDATE chapters
2209SET front_page_id = $1
2210WHERE id = $2
2211RETURNING *;
2212 "#,
2213 page.id,
2214 front_page_of_chapter_id
2215 )
2216 .fetch_one(&mut *tx)
2218 .await?;
2219 }
2220
2221 tx.commit().await?;
2222 Ok(Page {
2223 content: cms_page.page.content,
2224 course_id: page.course_id,
2225 exam_id: page.exam_id,
2226 created_at: page.created_at,
2227 updated_at: page.updated_at,
2228 deleted_at: page.deleted_at,
2229 id: page.id,
2230 title: cms_page.page.title,
2231 url_path: cms_page.page.url_path,
2232 order_number: page.order_number,
2233 chapter_id: page.chapter_id,
2234 copied_from: page.copied_from,
2235 hidden: page.hidden,
2236 page_language_group_id: page.page_language_group_id,
2237 })
2238}
2239
2240pub async fn delete_page_and_exercises(
2241 conn: &mut PgConnection,
2242 page_id: Uuid,
2243 author: Uuid,
2244) -> ModelResult<Page> {
2245 let mut tx = conn.begin().await?;
2246 let page = sqlx::query_as!(
2247 Page,
2248 r#"
2249UPDATE pages
2250SET deleted_at = now()
2251WHERE id = $1
2252AND deleted_at IS NULL
2253RETURNING id,
2254 created_at,
2255 updated_at,
2256 course_id,
2257 exam_id,
2258 chapter_id,
2259 url_path,
2260 title,
2261 deleted_at,
2262 content,
2263 order_number,
2264 copied_from,
2265 hidden,
2266 page_language_group_id
2267 "#,
2268 page_id,
2269 )
2270 .fetch_one(&mut *tx)
2271 .await?;
2272
2273 sqlx::query!(
2274 r#"
2275 UPDATE exercises
2276 SET deleted_at = now()
2277 WHERE page_id = $1
2278 AND deleted_at IS NULL
2279 "#,
2280 page_id,
2281 )
2282 .execute(&mut *tx)
2283 .await?;
2284
2285 sqlx::query!(
2286 "
2287UPDATE exercise_slides
2288SET deleted_at = now()
2289WHERE exercise_id IN (
2290 SELECT id
2291 FROM exercises
2292 WHERE page_id = $1
2293 )
2294 AND deleted_at IS NULL;
2295 ",
2296 page.id
2297 )
2298 .execute(&mut *tx)
2299 .await?;
2300
2301 sqlx::query!(
2302 r#"
2303UPDATE exercise_tasks
2304SET deleted_at = now()
2305WHERE exercise_slide_id IN (
2306 SELECT s.id
2307 FROM exercise_slides s
2308 JOIN exercises e ON (s.exercise_id = e.id)
2309 WHERE e.page_id = $1
2310 )
2311 AND deleted_at IS NULL;
2312 "#,
2313 page.id
2314 )
2315 .execute(&mut *tx)
2316 .await?;
2317
2318 let history_content = PageHistoryContent {
2319 content: json!(null),
2320 exercises: Vec::new(),
2321 exercise_slides: Vec::new(),
2322 exercise_tasks: Vec::new(),
2323 peer_or_self_review_configs: Vec::new(),
2324 peer_or_self_review_questions: Vec::new(),
2325 };
2326 crate::page_history::insert(
2327 &mut tx,
2328 PKeyPolicy::Generate,
2329 page.id,
2330 &page.title,
2331 &history_content,
2332 HistoryChangeReason::PageDeleted,
2333 author,
2334 None,
2335 )
2336 .await?;
2337
2338 tx.commit().await?;
2339 Ok(page)
2340}
2341
2342pub async fn get_chapters_pages_with_exercises(
2343 conn: &mut PgConnection,
2344 chapters_id: Uuid,
2345) -> ModelResult<Vec<PageWithExercises>> {
2346 let chapter_pages = sqlx::query_as!(
2347 Page,
2348 r#"
2349SELECT id,
2350 created_at,
2351 updated_at,
2352 course_id,
2353 exam_id,
2354 chapter_id,
2355 url_path,
2356 title,
2357 deleted_at,
2358 content,
2359 order_number,
2360 copied_from,
2361 hidden,
2362 page_language_group_id
2363FROM pages
2364WHERE chapter_id = $1
2365 AND deleted_at IS NULL
2366 "#,
2367 chapters_id
2368 )
2369 .fetch_all(&mut *conn)
2370 .await?;
2371 let page_ids: Vec<Uuid> = chapter_pages.iter().map(|page| page.id).collect();
2372 let pages_exercises = sqlx::query_as!(
2373 Exercise,
2374 r#"
2375SELECT *
2376FROM exercises
2377WHERE page_id IN (
2378 SELECT UNNEST($1::uuid [])
2379 )
2380 AND deleted_at IS NULL
2381 "#,
2382 &page_ids
2383 )
2384 .fetch_all(&mut *conn)
2385 .await?;
2386
2387 let mut page_to_exercises: HashMap<Uuid, Vec<Exercise>> = pages_exercises
2388 .into_iter()
2389 .into_group_map_by(|exercise| exercise.page_id);
2390 let mut chapter_pages_with_exercises: Vec<PageWithExercises> = chapter_pages
2391 .into_iter()
2392 .map(|page| {
2393 let page_id = page.id;
2394 let mut exercises = page_to_exercises.remove(&page_id).unwrap_or_default();
2395
2396 exercises.sort_by(|a, b| a.order_number.cmp(&b.order_number));
2397 PageWithExercises { page, exercises }
2398 })
2399 .collect();
2400
2401 chapter_pages_with_exercises.sort_by(|a, b| a.page.order_number.cmp(&b.page.order_number));
2402
2403 Ok(chapter_pages_with_exercises)
2404}
2405
2406pub async fn get_next_page(
2407 conn: &mut PgConnection,
2408 page_id: Uuid,
2409) -> ModelResult<Option<PageRoutingData>> {
2410 let page_metadata = get_current_page_metadata(conn, page_id).await?;
2411 let next_page = get_next_page_by_order_number(conn, &page_metadata).await?;
2412
2413 match next_page {
2414 Some(next_page) => Ok(Some(next_page)),
2415 None => {
2416 let first_page = get_next_page_by_chapter_number(conn, &page_metadata).await?;
2417 Ok(first_page)
2418 }
2419 }
2420}
2421
2422async fn get_current_page_metadata(
2423 conn: &mut PgConnection,
2424 page_id: Uuid,
2425) -> ModelResult<PageMetadata> {
2426 let page_metadata = sqlx::query_as!(
2427 PageMetadata,
2428 r#"
2429SELECT p.id as page_id,
2430 p.order_number as order_number,
2431 p.course_id as course_id,
2432 p.exam_id as exam_id,
2433 c.id as "chapter_id?",
2434 c.chapter_number as "chapter_number?"
2435FROM pages p
2436 LEFT JOIN chapters c ON p.chapter_id = c.id
2437WHERE p.id = $1;
2438"#,
2439 page_id
2440 )
2441 .fetch_one(conn)
2442 .await?;
2443
2444 if page_metadata.chapter_number.is_none() {
2445 return Err(ModelError::new(
2446 ModelErrorType::InvalidRequest,
2447 "Page is not related to any chapter".to_string(),
2448 None,
2449 ));
2450 }
2451
2452 Ok(page_metadata)
2453}
2454
2455async fn get_next_page_by_order_number(
2456 conn: &mut PgConnection,
2457 current_page_metadata: &PageMetadata,
2458) -> ModelResult<Option<PageRoutingData>> {
2459 let next_page = sqlx::query_as!(
2460 PageRoutingData,
2461 "
2462SELECT p.url_path as url_path,
2463 p.title as title,
2464 p.id as page_id,
2465 c.chapter_number as chapter_number,
2466 c.id as chapter_id,
2467 c.opens_at as chapter_opens_at,
2468 c.front_page_id as chapter_front_page_id
2469FROM pages p
2470 LEFT JOIN chapters c ON p.chapter_id = c.id
2471WHERE p.order_number = (
2472 SELECT MIN(pa.order_number)
2473 FROM pages pa
2474 WHERE pa.order_number > $1
2475 AND pa.deleted_at IS NULL
2476 )
2477 AND p.course_id = $2
2478 AND c.chapter_number = $3
2479 AND p.deleted_at IS NULL;
2480 ",
2481 current_page_metadata.order_number,
2482 current_page_metadata.course_id,
2483 current_page_metadata.chapter_number
2484 )
2485 .fetch_optional(conn)
2486 .await?;
2487
2488 Ok(next_page)
2489}
2490
2491async fn get_next_page_by_chapter_number(
2492 conn: &mut PgConnection,
2493 current_page_metadata: &PageMetadata,
2494) -> ModelResult<Option<PageRoutingData>> {
2495 let next_page = sqlx::query_as!(
2496 PageRoutingData,
2497 "
2498SELECT p.url_path as url_path,
2499 p.title as title,
2500 p.id as page_id,
2501 c.chapter_number as chapter_number,
2502 c.id as chapter_id,
2503 c.opens_at as chapter_opens_at,
2504 c.front_page_id as chapter_front_page_id
2505FROM chapters c
2506 INNER JOIN pages p on c.id = p.chapter_id
2507WHERE c.chapter_number = (
2508 SELECT MIN(ca.chapter_number)
2509 FROM chapters ca
2510 WHERE ca.chapter_number > $1
2511 AND ca.deleted_at IS NULL
2512 )
2513 AND c.course_id = $2
2514 AND p.deleted_at IS NULL
2515ORDER BY p.order_number
2516LIMIT 1;
2517 ",
2518 current_page_metadata.chapter_number,
2519 current_page_metadata.course_id
2520 )
2521 .fetch_optional(conn)
2522 .await?;
2523
2524 Ok(next_page)
2525}
2526
2527async fn get_next_page_order_number_in_chapter(
2528 conn: &mut PgConnection,
2529 chapter_id: Uuid,
2530) -> ModelResult<i32> {
2531 let next_order_number = sqlx::query!(
2532 "
2533select max(p.order_number) as order_number
2534from pages p
2535where p.chapter_id = $1
2536 and p.deleted_at is null;
2537",
2538 chapter_id
2539 )
2540 .fetch_one(conn)
2541 .await?;
2542
2543 match next_order_number.order_number {
2544 Some(order_number) => Ok(order_number + 1),
2545 None => Ok(0),
2546 }
2547}
2548
2549pub async fn get_page_navigation_data(
2550 conn: &mut PgConnection,
2551 page_id: Uuid,
2552) -> ModelResult<PageNavigationInformation> {
2553 let previous_page_data = get_previous_page(conn, page_id).await?;
2554
2555 let next_page_data = get_next_page(conn, page_id).await?;
2556
2557 let chapter_front_page = get_chapter_front_page_by_page_id(conn, page_id).await?;
2558 let chapter_front_page_chapter = OptionFuture::from(
2560 chapter_front_page
2561 .clone()
2562 .and_then(|front_page| front_page.chapter_id)
2563 .map(|chapter_id| chapters::get_chapter(conn, chapter_id)),
2564 )
2565 .await
2566 .transpose()?;
2567
2568 let chapter_front_page = chapter_front_page
2569 .map(|front_page| -> ModelResult<_> {
2570 if let Some(chapter_front_page_chapter) = chapter_front_page_chapter {
2571 Ok(PageRoutingData {
2572 url_path: front_page.url_path,
2573 title: front_page.title,
2574 page_id: front_page.id,
2575 chapter_number: chapter_front_page_chapter.chapter_number,
2576 chapter_id: chapter_front_page_chapter.id,
2577 chapter_opens_at: chapter_front_page_chapter.opens_at,
2578 chapter_front_page_id: Some(front_page.id),
2579 })
2580 } else {
2581 Err(ModelError::new(
2582 ModelErrorType::InvalidRequest,
2583 "Chapter front page chapter not found".to_string(),
2584 None,
2585 ))
2586 }
2587 })
2588 .transpose()?;
2589 Ok(PageNavigationInformation {
2590 chapter_front_page,
2591 next_page: next_page_data,
2592 previous_page: previous_page_data,
2593 })
2594}
2595
2596pub async fn get_previous_page(
2597 conn: &mut PgConnection,
2598 page_id: Uuid,
2599) -> ModelResult<Option<PageRoutingData>> {
2600 let page_metadata = get_current_page_metadata(conn, page_id).await?;
2601 let previous_page = get_previous_page_by_order_number(conn, &page_metadata).await?;
2602
2603 match previous_page {
2604 Some(previous_page) => Ok(Some(previous_page)),
2605 None => {
2606 let first_page = get_previous_page_by_chapter_number(conn, &page_metadata).await?;
2607 Ok(first_page)
2608 }
2609 }
2610}
2611
2612pub async fn get_chapter_front_page_by_page_id(
2613 conn: &mut PgConnection,
2614 page_id: Uuid,
2615) -> ModelResult<Option<Page>> {
2616 let page_metadata = get_current_page_metadata(conn, page_id).await?;
2617 let chapter = chapters::get_chapter_info_by_page_metadata(conn, &page_metadata).await?;
2618 let page_option_future: OptionFuture<_> = chapter
2619 .chapter_front_page_id
2620 .map(|chapter_front_page_id| get_page(conn, chapter_front_page_id))
2621 .into();
2622 let page = page_option_future.await.transpose()?;
2623 Ok(page)
2624}
2625
2626async fn get_previous_page_by_order_number(
2627 conn: &mut PgConnection,
2628 current_page_metadata: &PageMetadata,
2629) -> ModelResult<Option<PageRoutingData>> {
2630 let previous_page = sqlx::query_as!(
2631 PageRoutingData,
2632 "
2633SELECT p.url_path as url_path,
2634 p.title as title,
2635 c.chapter_number as chapter_number,
2636 p.id as page_id,
2637 c.id as chapter_id,
2638 c.opens_at as chapter_opens_at,
2639 c.front_page_id as chapter_front_page_id
2640FROM pages p
2641 LEFT JOIN chapters c ON p.chapter_id = c.id
2642WHERE p.order_number = (
2643 SELECT MAX(pa.order_number)
2644 FROM pages pa
2645 WHERE pa.order_number < $1
2646 AND pa.deleted_at IS NULL
2647 )
2648 AND p.course_id = $2
2649 AND c.chapter_number = $3
2650 AND p.deleted_at IS NULL;
2651 ",
2652 current_page_metadata.order_number,
2653 current_page_metadata.course_id,
2654 current_page_metadata.chapter_number
2655 )
2656 .fetch_optional(conn)
2657 .await?;
2658
2659 Ok(previous_page)
2660}
2661
2662async fn get_previous_page_by_chapter_number(
2663 conn: &mut PgConnection,
2664 current_page_metadata: &PageMetadata,
2665) -> ModelResult<Option<PageRoutingData>> {
2666 let previous_page = sqlx::query_as!(
2667 PageRoutingData,
2668 "
2669SELECT p.url_path AS url_path,
2670 p.title AS title,
2671 p.id AS page_id,
2672 c.chapter_number AS chapter_number,
2673 c.id AS chapter_id,
2674 c.opens_at AS chapter_opens_at,
2675 c.front_page_id AS chapter_front_page_id
2676FROM chapters c
2677 INNER JOIN pages p ON c.id = p.chapter_id
2678WHERE c.chapter_number = (
2679 SELECT MAX(ca.chapter_number)
2680 FROM chapters ca
2681 WHERE ca.chapter_number < $1
2682 AND ca.deleted_at IS NULL
2683 )
2684 AND c.course_id = $2
2685 AND p.deleted_at IS NULL
2686ORDER BY p.order_number DESC
2687LIMIT 1;
2688 ",
2689 current_page_metadata.chapter_number,
2690 current_page_metadata.course_id
2691 )
2692 .fetch_optional(conn)
2693 .await?;
2694
2695 Ok(previous_page)
2696}
2697
2698async fn get_next_order_number_for_courses_top_level_pages(
2699 conn: &mut PgConnection,
2700 course_id: Uuid,
2701) -> ModelResult<i32> {
2702 let next_order_number = sqlx::query!(
2703 "
2704select max(p.order_number) as order_number
2705from pages p
2706where p.course_id = $1
2707 and p.chapter_id is null
2708 and p.deleted_at is null;
2709",
2710 course_id
2711 )
2712 .fetch_one(conn)
2713 .await?;
2714
2715 match next_order_number.order_number {
2716 Some(order_number) => Ok(order_number + 1),
2717 None => Ok(0),
2718 }
2719}
2720
2721pub async fn get_chapter_pages(
2722 conn: &mut PgConnection,
2723 chapter_id: Uuid,
2724) -> ModelResult<Vec<Page>> {
2725 let pages = sqlx::query_as!(
2726 Page,
2727 "
2728SELECT id,
2729 created_at,
2730 updated_at,
2731 course_id,
2732 exam_id,
2733 chapter_id,
2734 url_path,
2735 title,
2736 deleted_at,
2737 content,
2738 order_number,
2739 copied_from,
2740 hidden,
2741 page_language_group_id
2742FROM pages p
2743WHERE p.chapter_id = $1
2744 AND p.deleted_at IS NULL;
2745 ",
2746 chapter_id
2747 )
2748 .fetch_all(conn)
2749 .await?;
2750
2751 Ok(pages)
2752}
2753
2754pub async fn get_chapters_visible_pages_exclude_main_frontpage(
2755 conn: &mut PgConnection,
2756 chapter_id: Uuid,
2757) -> ModelResult<Vec<Page>> {
2758 let pages = sqlx::query_as!(
2759 Page,
2760 "
2761SELECT id,
2762 created_at,
2763 updated_at,
2764 course_id,
2765 exam_id,
2766 chapter_id,
2767 url_path,
2768 title,
2769 deleted_at,
2770 content,
2771 order_number,
2772 copied_from,
2773 hidden,
2774 page_language_group_id
2775FROM pages p
2776WHERE p.chapter_id = $1
2777 AND p.deleted_at IS NULL
2778 AND p.hidden IS FALSE
2779 AND p.url_path IS NOT NULL
2780 AND p.id NOT IN (
2781 SELECT front_page_id
2782 FROM chapters c
2783 WHERE c.front_page_id = p.id
2784 );
2785 ",
2786 chapter_id
2787 )
2788 .fetch_all(conn)
2789 .await?;
2790 Ok(pages)
2791}
2792
2793pub async fn get_page_search_results_for_phrase(
2797 conn: &mut PgConnection,
2798 course_id: Uuid,
2799 page_search_request: &SearchRequest,
2800) -> ModelResult<Vec<PageSearchResult>> {
2801 let course = crate::courses::get_course(&mut *conn, course_id).await?;
2802
2803 let last_word = if let Some(last) = page_search_request
2806 .query
2807 .trim()
2808 .split_ascii_whitespace()
2809 .last()
2810 {
2811 last
2812 } else {
2813 return Ok(Vec::new());
2814 };
2815
2816 let res = sqlx::query_as!(
2817 PageSearchResult,
2818 r#"
2819-- common table expression for the search term tsquery so that we don't have to repeat it many times
2820WITH cte as (
2821 -- Converts the search term to a phrase search with phraseto_tsquery but appends ':*' to the last word so that it
2822 -- becomes a prefix match. This way the search will also contain results when the last word in the search term
2823 -- is only partially typed. Note that if to_tsquery($4) decides to stem the word, the replacement will be skipped.
2824 SELECT ts_rewrite(
2825 phraseto_tsquery($2::regconfig, $3),
2826 to_tsquery($4),
2827 to_tsquery($4 || ':*')
2828 ) as query
2829)
2830SELECT p.id,
2831 ts_rank(
2832 p.content_search,
2833 (
2834 SELECT query
2835 from cte
2836 )
2837 ) as rank,
2838 ts_headline(
2839 $2::regconfig,
2840 p.title,
2841 (
2842 SELECT query
2843 from cte
2844 ),
2845 'MaxFragments=1, MaxWords=20, MinWords=1'
2846 ) as title_headline,
2847 ts_headline(
2848 $2::regconfig,
2849 p.content_search_original_text,
2850 (
2851 SELECT query
2852 from cte
2853 ),
2854 'MaxFragments=0, MaxWords=120, MinWords=70'
2855 ) as content_headline,
2856 COALESCE(p.url_path, '') as "url_path!",
2857 c.name as "chapter_name?"
2858FROM pages p
2859LEFT JOIN chapters c ON p.chapter_id = c.id
2860WHERE p.course_id = $1
2861 AND p.deleted_at IS NULL
2862 AND p.hidden IS FALSE
2863 AND p.url_path IS NOT NULL
2864 AND p.content_search @@ (
2865 SELECT query
2866 from cte
2867 )
2868ORDER BY rank DESC
2869LIMIT 50;
2870 "#,
2871 course_id,
2872 course.content_search_language as _,
2873 page_search_request.query,
2874 last_word
2875 )
2876 .fetch_all(conn)
2877 .await?;
2878
2879 Ok(add_course_url_prefix_to_search_results(res, &course))
2880}
2881
2882pub async fn get_page_search_results_for_words(
2886 conn: &mut PgConnection,
2887 course_id: Uuid,
2888 page_search_request: &SearchRequest,
2889) -> ModelResult<Vec<PageSearchResult>> {
2890 let course = crate::courses::get_course(&mut *conn, course_id).await?;
2891
2892 let last_word = if let Some(last) = page_search_request
2895 .query
2896 .trim()
2897 .split_ascii_whitespace()
2898 .last()
2899 {
2900 last
2901 } else {
2902 return Ok(Vec::new());
2903 };
2904
2905 let res = sqlx::query_as!(
2906 PageSearchResult,
2907 r#"
2908-- common table expression for the search term tsquery so that we don't have to repeat it many times
2909WITH cte as (
2910 -- Converts the search term to a word search with ands between the words with plainto_tsquery but appends ':*' to the
2911 -- last word so that it becomes a prefix match. This way the search will also contain results when the last word in
2912 -- the search term is only partially typed. Note that if to_tsquery($4) decides to stem the word, the replacement
2913 -- will be skipped.
2914 SELECT ts_rewrite(
2915 plainto_tsquery($2::regconfig, $3),
2916 to_tsquery($4),
2917 to_tsquery($4 || ':*')
2918 ) as query
2919)
2920SELECT p.id,
2921 ts_rank(
2922 p.content_search,
2923 (
2924 SELECT query
2925 from cte
2926 )
2927 ) as rank,
2928 ts_headline(
2929 $2::regconfig,
2930 p.title,
2931 (
2932 SELECT query
2933 from cte
2934 ),
2935 'MaxFragments=1, MaxWords=20, MinWords=1'
2936 ) as title_headline,
2937 ts_headline(
2938 $2::regconfig,
2939 p.content_search_original_text,
2940 (
2941 SELECT query
2942 from cte
2943 ),
2944 'MaxFragments=0, MaxWords=120, MinWords=70'
2945 ) as content_headline,
2946 COALESCE(p.url_path, '') as "url_path!",
2947 c.name as "chapter_name?"
2948FROM pages p
2949LEFT JOIN chapters c ON p.chapter_id = c.id
2950WHERE p.course_id = $1
2951 AND p.deleted_at IS NULL
2952 AND p.hidden IS FALSE
2953 AND p.url_path IS NOT NULL
2954 AND p.content_search @@ (
2955 SELECT query
2956 from cte
2957 )
2958ORDER BY rank DESC
2959LIMIT 50;
2960 "#,
2961 course_id,
2962 course.content_search_language as _,
2963 page_search_request.query,
2964 last_word
2965 )
2966 .fetch_all(conn)
2967 .await?;
2968
2969 Ok(add_course_url_prefix_to_search_results(res, &course))
2970}
2971
2972fn add_course_url_prefix_to_search_results(
2973 search_results: Vec<PageSearchResult>,
2974 course: &Course,
2975) -> Vec<PageSearchResult> {
2976 search_results
2977 .into_iter()
2978 .map(|mut sr| {
2979 let optional_slash = if sr.url_path.starts_with('/') {
2980 ""
2981 } else {
2982 "/"
2983 };
2984 sr.url_path = format!("/{}{}{}", course.slug, optional_slash, sr.url_path);
2985 sr
2986 })
2987 .collect()
2988}
2989
2990pub async fn restore(
2992 conn: &mut PgConnection,
2993 page_id: Uuid,
2994 history_id: Uuid,
2995 author: Uuid,
2996 spec_fetcher: impl SpecFetcher,
2997 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2998) -> ModelResult<Uuid> {
2999 let page = get_page(conn, page_id).await?;
3001 let history_data = page_history::get_history_data(conn, history_id).await?;
3002
3003 let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(history_data.content.content)?;
3004
3005 update_page(
3006 conn,
3007 PageUpdateArgs {
3008 page_id: page.id,
3009 author,
3010 cms_page_update: CmsPageUpdate {
3011 content: parsed_content,
3012 exercises: history_data.content.exercises,
3013 exercise_slides: history_data.content.exercise_slides,
3014 exercise_tasks: history_data.content.exercise_tasks,
3015 url_path: page.url_path,
3016 title: history_data.title,
3017 chapter_id: page.chapter_id,
3018 },
3019 retain_ids: true,
3020 history_change_reason: HistoryChangeReason::HistoryRestored,
3021 is_exam_page: history_data.exam_id.is_some(),
3022 },
3023 spec_fetcher,
3024 fetch_service_info,
3025 )
3026 .await?;
3027
3028 Ok(history_id)
3029}
3030
3031pub async fn get_organization_id(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<Uuid> {
3032 let res = sqlx::query!(
3033 "
3034SELECT organizations.id
3035FROM pages
3036 LEFT OUTER JOIN courses ON courses.id = pages.course_id
3037 LEFT OUTER JOIN exams ON exams.id = pages.exam_id
3038 JOIN organizations ON organizations.id = courses.organization_id
3039 OR organizations.id = exams.organization_id
3040WHERE pages.id = $1
3041",
3042 page_id,
3043 )
3044 .fetch_one(&mut *conn)
3045 .await?;
3046 Ok(res.id)
3047}
3048
3049pub async fn get_page_chapter_and_course_information(
3050 conn: &mut PgConnection,
3051 page_id: Uuid,
3052) -> ModelResult<PageChapterAndCourseInformation> {
3053 let res = sqlx::query_as!(
3054 PageChapterAndCourseInformation,
3055 r#"
3056SELECT chapters.name as "chapter_name?",
3057 chapters.chapter_number as "chapter_number?",
3058 courses.name as "course_name?",
3059 courses.slug as "course_slug?",
3060 chapters.front_page_id as "chapter_front_page_id?",
3061 p2.url_path as "chapter_front_page_url_path?",
3062 organizations.slug as organization_slug
3063FROM pages
3064 LEFT JOIN chapters on pages.chapter_id = chapters.id
3065 LEFT JOIN courses on pages.course_id = courses.id
3066 LEFT JOIN pages p2 ON chapters.front_page_id = p2.id
3067 LEFT JOIN organizations on courses.organization_id = organizations.id
3068WHERE pages.id = $1
3069"#,
3070 page_id,
3071 )
3072 .fetch_one(&mut *conn)
3073 .await?;
3074 Ok(res)
3075}
3076
3077pub async fn get_page_by_course_id_and_language_group(
3078 conn: &mut PgConnection,
3079 course_id: Uuid,
3080 page_language_group_id: Uuid,
3081) -> ModelResult<Page> {
3082 let page = sqlx::query_as!(
3083 Page,
3084 "
3085SELECT id,
3086 created_at,
3087 updated_at,
3088 course_id,
3089 exam_id,
3090 chapter_id,
3091 url_path,
3092 title,
3093 deleted_at,
3094 content,
3095 order_number,
3096 copied_from,
3097 hidden,
3098 page_language_group_id
3099FROM pages p
3100WHERE p.course_id = $1
3101 AND p.page_language_group_id = $2
3102 AND p.deleted_at IS NULL
3103 ",
3104 course_id,
3105 page_language_group_id
3106 )
3107 .fetch_one(&mut *conn)
3108 .await?;
3109 Ok(page)
3110}
3111
3112pub async fn reorder_pages(
3116 conn: &mut PgConnection,
3117 pages: &[Page],
3118 course_id: Uuid,
3119) -> ModelResult<()> {
3120 let db_pages =
3121 get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
3122 let chapters = course_chapters(conn, course_id).await?;
3123
3124 let mut chapter_pages: HashMap<Option<Uuid>, Vec<&Page>> = HashMap::new();
3125
3126 for page in pages {
3127 chapter_pages.entry(page.chapter_id).or_default().push(page);
3128 }
3129
3130 let mut normalized_pages = Vec::with_capacity(pages.len());
3131
3132 for (_, chapter_pages) in chapter_pages.iter() {
3133 let mut sorted_pages = chapter_pages.to_vec();
3135 sorted_pages.sort_by(|a, b| {
3136 a.order_number
3137 .cmp(&b.order_number)
3138 .then_with(|| a.id.cmp(&b.id))
3139 });
3140
3141 for (idx, &page) in sorted_pages.iter().enumerate() {
3143 let mut normalized_page = page.clone();
3144 normalized_page.order_number = (idx as i32) + 1; normalized_pages.push(normalized_page);
3146 }
3147 }
3148
3149 let mut tx = conn.begin().await?;
3150
3151 sqlx::query!(
3155 "
3156UPDATE pages
3157SET order_number = floor(random() * (2000000 -200000 + 1) + 200000)
3158WHERE course_id = $1
3159 AND order_number != 0
3160 AND deleted_at IS NULL
3161 ",
3162 course_id
3163 )
3164 .execute(&mut *tx)
3165 .await?;
3166
3167 for page in normalized_pages {
3169 if let Some(matching_db_page) = db_pages.iter().find(|p| p.id == page.id) {
3170 if matching_db_page.chapter_id == page.chapter_id {
3171 sqlx::query!(
3173 "UPDATE pages SET order_number = $2 WHERE id = $1",
3174 page.id,
3175 page.order_number
3176 )
3177 .execute(&mut *tx)
3178 .await?;
3179 } else {
3180 if let Some(old_chapter_id) = matching_db_page.chapter_id {
3182 if let Some(new_chapter_id) = page.chapter_id {
3183 if let Some(old_chapter) = chapters.iter().find(|o| o.id == old_chapter_id)
3185 {
3186 if let Some(new_chapter) =
3187 chapters.iter().find(|o| o.id == new_chapter_id)
3188 {
3189 let old_path = &page.url_path;
3190 let new_path = old_path.replacen(
3191 &old_chapter.chapter_number.to_string(),
3192 &new_chapter.chapter_number.to_string(),
3193 1,
3194 );
3195 sqlx::query!(
3196 "UPDATE pages SET url_path = $2, chapter_id = $3, order_number = $4 WHERE pages.id = $1",
3197 page.id,
3198 new_path,
3199 new_chapter.id,
3200 page.order_number
3201 )
3202 .execute(&mut *tx)
3203 .await?;
3204 sqlx::query!(
3205 "INSERT INTO url_redirections(destination_page_id, old_url_path, course_id) VALUES ($1, $2, $3)",
3206 page.id,
3207 old_path,
3208 course_id
3209 )
3210 .execute(&mut *tx)
3211 .await?;
3212 } else {
3213 return Err(ModelError::new(
3214 ModelErrorType::InvalidRequest,
3215 "New chapter not found".to_string(),
3216 None,
3217 ));
3218 }
3219 } else {
3220 return Err(ModelError::new(
3221 ModelErrorType::InvalidRequest,
3222 "Old chapter not found".to_string(),
3223 None,
3224 ));
3225 }
3226 } else {
3227 return Err(ModelError::new(
3229 ModelErrorType::InvalidRequest,
3230 "Making a chapter page a top level page is not supported yet"
3231 .to_string(),
3232 None,
3233 ));
3234 }
3235 } else {
3236 error!(
3237 "Cannot move a top level page to a chapter. matching_db_page.chapter_id: {:?} page.chapter_id: {:?}",
3238 matching_db_page.chapter_id, page.chapter_id
3239 );
3240 return Err(ModelError::new(
3242 ModelErrorType::InvalidRequest,
3243 "Moving a top level page to a chapter is not supported yet".to_string(),
3244 None,
3245 ));
3246 }
3247 }
3248 } else {
3249 return Err(ModelError::new(
3250 ModelErrorType::InvalidRequest,
3251 format!("Page {} does exist in course {}", page.id, course_id),
3252 None,
3253 ));
3254 }
3255 }
3256 tx.commit().await?;
3257 Ok(())
3258}
3259
3260pub async fn reorder_chapters(
3261 conn: &mut PgConnection,
3262 chapters: &[Chapter],
3263 course_id: Uuid,
3264) -> ModelResult<()> {
3265 let db_chapters = course_chapters(conn, course_id).await?;
3266 let mut tx = conn.begin().await?;
3267 for chapter in chapters {
3271 if let Some(matching_db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id)
3272 && let Some(old_chapter) = db_chapters.iter().find(|o| o.id == matching_db_chapter.id)
3273 {
3274 sqlx::query!(
3277 "UPDATE chapters
3278 SET chapter_number = floor(random() * (20000000 - 2000000 + 1) + 200000)
3279 WHERE chapters.id = $1
3280 AND chapters.course_id = $2
3281 AND deleted_at IS NULL",
3282 matching_db_chapter.id,
3283 course_id
3284 )
3285 .execute(&mut *tx)
3286 .await?;
3287
3288 let chapter_with_randomized_chapter_number =
3290 get_chapter(&mut tx, matching_db_chapter.id).await?;
3291 let random_chapter_number = chapter_with_randomized_chapter_number.chapter_number;
3292 let pages =
3293 get_chapter_pages(&mut tx, chapter_with_randomized_chapter_number.id).await?;
3294
3295 for page in pages {
3296 let old_path = &page.url_path;
3297 let new_path = old_path.replacen(
3298 &old_chapter.chapter_number.to_string(),
3299 &random_chapter_number.to_string(),
3300 1,
3301 );
3302
3303 sqlx::query!(
3305 "UPDATE pages SET url_path = $2 WHERE pages.id = $1",
3306 page.id,
3307 new_path
3308 )
3309 .execute(&mut *tx)
3310 .await?;
3311 }
3312 }
3313 }
3314
3315 for chapter in chapters {
3316 if let Some(matching_db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id) {
3317 if let Some(new_chapter) = chapters.iter().find(|o| o.id == matching_db_chapter.id) {
3318 let new_chapter_number = &new_chapter.chapter_number;
3319
3320 let randomized_chapter = get_chapter(&mut tx, chapter.id).await?;
3321
3322 let randomized_chapter_number = randomized_chapter.chapter_number;
3323
3324 sqlx::query!(
3326 "UPDATE chapters SET chapter_number = $2 WHERE chapters.id = $1",
3327 chapter.id,
3328 new_chapter_number
3329 )
3330 .execute(&mut *tx)
3331 .await?;
3332
3333 let pages = get_chapter_pages(&mut tx, chapter.id).await?;
3335
3336 for page in pages {
3337 let path_with_temp_random_number = &page.url_path;
3338 let new_path = path_with_temp_random_number.replacen(
3339 &randomized_chapter_number.to_string(),
3340 &new_chapter_number.to_string(),
3341 1,
3342 );
3343 let old_path = path_with_temp_random_number.replacen(
3344 &randomized_chapter_number.to_string(),
3345 &chapter.chapter_number.to_string(),
3346 1,
3347 );
3348 sqlx::query!(
3350 "UPDATE pages SET url_path = $2 WHERE pages.id = $1",
3351 page.id,
3352 new_path
3353 )
3354 .execute(&mut *tx)
3355 .await?;
3356
3357 crate::url_redirections::upsert(
3358 &mut tx,
3359 PKeyPolicy::Generate,
3360 page.id,
3361 &old_path,
3362 course_id,
3363 )
3364 .await?;
3365 }
3366 } else {
3367 return Err(ModelError::new(
3368 ModelErrorType::InvalidRequest,
3369 "New chapter not found".to_string(),
3370 None,
3371 ));
3372 }
3373 } else {
3374 return Err(ModelError::new(
3375 ModelErrorType::InvalidRequest,
3376 "Matching DB chapters not found".to_string(),
3377 None,
3378 ));
3379 }
3380 }
3381
3382 tx.commit().await?;
3383 Ok(())
3384}
3385
3386pub async fn is_chapter_front_page(
3387 conn: &mut PgConnection,
3388 page_id: Uuid,
3389) -> ModelResult<IsChapterFrontPage> {
3390 let chapter = get_chapter_by_page_id(conn, page_id).await?;
3391
3392 Ok(chapter.front_page_id.map_or(
3393 IsChapterFrontPage {
3394 is_chapter_front_page: false,
3395 },
3396 |id| IsChapterFrontPage {
3397 is_chapter_front_page: id == page_id,
3398 },
3399 ))
3400}
3401
3402pub async fn update_page_details(
3403 conn: &mut PgConnection,
3404 page_id: Uuid,
3405 page_details_update: &PageDetailsUpdate,
3406) -> ModelResult<()> {
3407 let mut tx = conn.begin().await?;
3408 let page_before_update = get_page(&mut tx, page_id).await?;
3409 sqlx::query!(
3410 "
3411UPDATE pages
3412SET title = $2,
3413 url_path = $3
3414WHERE id = $1
3415",
3416 page_id,
3417 page_details_update.title,
3418 page_details_update.url_path,
3419 )
3420 .execute(&mut *tx)
3421 .await?;
3422
3423 if let Some(course_id) = page_before_update.course_id
3424 && page_before_update.url_path != page_details_update.url_path
3425 {
3426 crate::url_redirections::upsert(
3428 &mut tx,
3429 PKeyPolicy::Generate,
3430 page_id,
3431 &page_before_update.url_path,
3432 course_id,
3433 )
3434 .await?;
3435 }
3436
3437 tx.commit().await?;
3438 Ok(())
3439}
3440
3441pub async fn get_by_ids_and_visibility(
3442 conn: &mut PgConnection,
3443 ids: &[Uuid],
3444 page_visibility: PageVisibility,
3445) -> ModelResult<Vec<Page>> {
3446 let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
3447 let pages = sqlx::query_as!(
3448 Page,
3449 "
3450SELECT id,
3451 created_at,
3452 updated_at,
3453 course_id,
3454 exam_id,
3455 chapter_id,
3456 url_path,
3457 title,
3458 deleted_at,
3459 content,
3460 order_number,
3461 copied_from,
3462 hidden,
3463 page_language_group_id
3464FROM pages
3465WHERE id = ANY($1)
3466 AND hidden IS DISTINCT FROM $2
3467 AND deleted_at IS NULL
3468 ",
3469 ids,
3470 inverse_visibility_filter
3471 )
3472 .fetch_all(conn)
3473 .await?;
3474 Ok(pages)
3475}
3476
3477pub async fn get_by_ids_deleted_and_visibility(
3478 conn: &mut PgConnection,
3479 ids: &[Uuid],
3480 page_visibility: PageVisibility,
3481) -> ModelResult<Vec<Page>> {
3482 let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
3483 let pages = sqlx::query_as!(
3484 Page,
3485 "
3486SELECT id,
3487 created_at,
3488 updated_at,
3489 course_id,
3490 exam_id,
3491 chapter_id,
3492 url_path,
3493 title,
3494 deleted_at,
3495 content,
3496 order_number,
3497 copied_from,
3498 hidden,
3499 page_language_group_id
3500FROM pages
3501WHERE id = ANY($1)
3502 AND hidden IS DISTINCT FROM $2
3503 AND deleted_at IS NOT NULL
3504 ",
3505 ids,
3506 inverse_visibility_filter
3507 )
3508 .fetch_all(conn)
3509 .await?;
3510 Ok(pages)
3511}
3512
3513#[cfg(test)]
3514mod test {
3515 use chrono::TimeZone;
3516
3517 use super::*;
3518 use crate::{exams::NewExam, test_helper::*};
3519
3520 #[tokio::test]
3521 async fn gets_organization_id() {
3522 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, :page);
3523
3524 let course_page_org = get_organization_id(tx.as_mut(), page).await.unwrap();
3525 assert_eq!(org, course_page_org);
3526
3527 let new_exam_id = crate::exams::insert(
3528 tx.as_mut(),
3529 PKeyPolicy::Generate,
3530 &NewExam {
3531 name: "name".to_string(),
3532 starts_at: None,
3533 ends_at: None,
3534 time_minutes: 120,
3535 organization_id: org,
3536 minimum_points_treshold: 24,
3537 grade_manually: false,
3538 },
3539 )
3540 .await
3541 .unwrap();
3542 let page = crate::pages::insert_page(
3543 tx.as_mut(),
3544 NewPage {
3545 exercises: vec![],
3546 exercise_slides: vec![],
3547 exercise_tasks: vec![],
3548 content: vec![],
3549 url_path: "url".to_string(),
3550 title: "title".to_string(),
3551 course_id: None,
3552 exam_id: Some(new_exam_id),
3553 chapter_id: None,
3554 front_page_of_chapter_id: None,
3555 content_search_language: None,
3556 },
3557 user,
3558 |_, _, _| unimplemented!(),
3559 |_| unimplemented!(),
3560 )
3561 .await
3562 .unwrap();
3563 let exam_page_org = get_organization_id(tx.as_mut(), page.id).await.unwrap();
3564 assert_eq!(org, exam_page_org);
3565 }
3566
3567 #[tokio::test]
3568 async fn page_update_validation_works() {
3569 let e1 = CmsPageExercise {
3570 id: Uuid::parse_str("0c9dca80-5904-4d35-a945-8c080446f667").unwrap(),
3571 name: "".to_string(),
3572 order_number: 1,
3573 score_maximum: 1,
3574 max_tries_per_slide: None,
3575 limit_number_of_tries: false,
3576 deadline: Some(Utc.with_ymd_and_hms(2125, 1, 1, 23, 59, 59).unwrap()),
3577 needs_peer_review: false,
3578 needs_self_review: false,
3579 peer_or_self_review_config: None,
3580 peer_or_self_review_questions: None,
3581 use_course_default_peer_or_self_review_config: false,
3582 teacher_reviews_answer_after_locking: true,
3583 };
3584 let e1_s1 = CmsPageExerciseSlide {
3585 id: Uuid::parse_str("43380e81-6ff2-4f46-9f38-af0ac6a8421a").unwrap(),
3586 exercise_id: e1.id,
3587 order_number: 1,
3588 };
3589 let e1_s1_t1 = CmsPageExerciseTask {
3590 id: Uuid::parse_str("6fb19c22-bca0-42cf-8be5-4141e21cc7a9").unwrap(),
3591 exercise_slide_id: e1_s1.id,
3592 assignment: serde_json::json!([]),
3593 exercise_type: "exercise".to_string(),
3594 private_spec: None,
3595 order_number: 1,
3596 };
3597
3598 assert!(
3600 create_update(vec![], vec![], vec![])
3601 .validate_exercise_data()
3602 .is_ok()
3603 );
3604
3605 assert!(
3607 create_update(
3608 vec![e1.clone()],
3609 vec![e1_s1.clone()],
3610 vec![e1_s1_t1.clone()],
3611 )
3612 .validate_exercise_data()
3613 .is_ok()
3614 );
3615
3616 assert!(
3618 create_update(vec![e1.clone()], vec![], vec![e1_s1_t1],)
3619 .validate_exercise_data()
3620 .is_err()
3621 );
3622
3623 assert!(
3625 create_update(vec![e1], vec![e1_s1], vec![],)
3626 .validate_exercise_data()
3627 .is_err()
3628 );
3629 }
3630
3631 fn create_update(
3632 exercises: Vec<CmsPageExercise>,
3633 exercise_slides: Vec<CmsPageExerciseSlide>,
3634 exercise_tasks: Vec<CmsPageExerciseTask>,
3635 ) -> CmsPageUpdate {
3636 CmsPageUpdate {
3637 content: vec![],
3638 exercises,
3639 exercise_slides,
3640 exercise_tasks,
3641 url_path: "".to_string(),
3642 title: "".to_string(),
3643 chapter_id: None,
3644 }
3645 }
3646
3647 #[tokio::test]
3648 async fn page_upsert_peer_reviews_work() {
3649 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
3650 let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
3651 let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
3652 let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
3653 .await
3654 .unwrap();
3655
3656 let pr1 = CmsPeerOrSelfReviewConfig {
3657 id:pr_id,
3658 exercise_id: Some(exercise_id),
3659 course_id: course,
3660 processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
3661 accepting_threshold: 0.5,
3662 peer_reviews_to_give: 2,
3663 peer_reviews_to_receive: 1,
3664 points_are_all_or_nothing: false,
3665 reset_answer_if_zero_points_from_review: false,
3666 review_instructions: None,
3667 };
3668 let prq = CmsPeerOrSelfReviewQuestion {
3669 id: prq_id,
3670 peer_or_self_review_config_id: pr_id,
3671 answer_required: true,
3672 order_number: 0,
3673 question: "juu".to_string(),
3674 question_type:
3675 crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
3676 weight: 0.31,
3677 };
3678 let mut remapped_exercises = HashMap::new();
3679 remapped_exercises.insert(exercise_id, exercise);
3680 let pr_res = upsert_peer_or_self_review_configs(
3681 tx.as_mut(),
3682 &[],
3683 &[pr1],
3684 &remapped_exercises,
3685 false,
3686 )
3687 .await
3688 .unwrap();
3689 let prq_res =
3690 upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, false)
3691 .await
3692 .unwrap();
3693
3694 assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
3695
3696 assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
3697 assert!(!pr_res.get(&pr_id).unwrap().points_are_all_or_nothing);
3698 assert_eq!(prq_res.get(&prq_id).unwrap().weight, 0.31);
3699 }
3700
3701 #[tokio::test]
3702 async fn page_upsert_peer_reviews_work_retain_ids() {
3703 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
3704 let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
3705 .await
3706 .unwrap();
3707 let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
3708 let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
3709 let pr1 = CmsPeerOrSelfReviewConfig {
3710 id:pr_id,
3711 exercise_id: Some(exercise_id),
3712 course_id: course,
3713 processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
3714 accepting_threshold: 0.5,
3715 peer_reviews_to_give: 2,
3716 peer_reviews_to_receive: 1,
3717 points_are_all_or_nothing: true,
3718 reset_answer_if_zero_points_from_review: false,
3719 review_instructions: None,
3720 };
3721 let prq = CmsPeerOrSelfReviewQuestion {
3722 id: prq_id,
3723 peer_or_self_review_config_id: pr_id,
3724 answer_required: true,
3725 order_number: 0,
3726 question: "juu".to_string(),
3727 question_type:
3728 crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
3729 weight: 0.0,
3730 };
3731 let mut remapped_exercises = HashMap::new();
3732 remapped_exercises.insert(exercise_id, exercise);
3733 let pr_res =
3734 upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[pr1], &remapped_exercises, true)
3735 .await
3736 .unwrap();
3737 let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, true)
3738 .await
3739 .unwrap();
3740
3741 assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
3742 assert!(pr_res.get(&pr_id).unwrap().id == pr_id);
3743
3744 assert!(prq_res.get(&prq_id).unwrap().id == prq_id);
3745 assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
3746 }
3747
3748 #[tokio::test]
3749 async fn page_upsert_peer_reviews_work_empty() {
3750 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
3751 let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
3752 .await
3753 .unwrap();
3754 let mut remapped_exercises = HashMap::new();
3755 remapped_exercises.insert(exercise_id, exercise);
3756 let pr_res =
3757 upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[], &remapped_exercises, true)
3758 .await
3759 .unwrap();
3760 let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[], &pr_res, true)
3761 .await
3762 .unwrap();
3763
3764 assert!(pr_res.is_empty());
3765 assert!(prq_res.is_empty());
3766 }
3767
3768 #[tokio::test]
3769 async fn reorder_top_level_pages_works() {
3770 insert_data!(:tx, :user, :org, :course);
3771
3772 let existing_pages =
3774 get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
3775 .await
3776 .unwrap();
3777 for page in &existing_pages {
3778 delete_page_and_exercises(tx.as_mut(), page.id, user)
3779 .await
3780 .unwrap();
3781 }
3782
3783 let page1 = NewCoursePage::new(course, 0, "top-page-1", "Top Page 1");
3785 let (page1_id, _) = insert_course_page(tx.as_mut(), &page1, user).await.unwrap();
3786 let page2 = NewCoursePage::new(course, 1, "top-page-2", "Top Page 2");
3787 let (page2_id, _) = insert_course_page(tx.as_mut(), &page2, user).await.unwrap();
3788 let page3 = NewCoursePage::new(course, 2, "top-page-3", "Top Page 3");
3789 let (page3_id, _) = insert_course_page(tx.as_mut(), &page3, user).await.unwrap();
3790
3791 let mut pages =
3792 get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
3793 .await
3794 .unwrap();
3795
3796 let page1_index = pages.iter().position(|p| p.id == page1_id).unwrap();
3797 let page2_index = pages.iter().position(|p| p.id == page2_id).unwrap();
3798 let page3_index = pages.iter().position(|p| p.id == page3_id).unwrap();
3799
3800 pages[page1_index].order_number = 2;
3801 pages[page3_index].order_number = 1;
3802 pages[page2_index].order_number = 3;
3803
3804 reorder_pages(tx.as_mut(), &pages, course).await.unwrap();
3806
3807 let page1_updated = get_page(tx.as_mut(), page1_id).await.unwrap();
3809 let page2_updated = get_page(tx.as_mut(), page2_id).await.unwrap();
3810 let page3_updated = get_page(tx.as_mut(), page3_id).await.unwrap();
3811
3812 assert_eq!(page1_updated.order_number, 2);
3813 assert_eq!(page2_updated.order_number, 3);
3814 assert_eq!(page3_updated.order_number, 1);
3815 }
3816}