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