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