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