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