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