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