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