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, filter_lock_chapter_blocks,
6 replace_duplicate_client_ids,
7};
8use itertools::Itertools;
9use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
10use serde_json::{Value, json};
11use sqlx::{AssertSqlSafe, Postgres, QueryBuilder, Row};
12use url::Url;
13use utoipa::ToSchema;
14
15use crate::{
16 CourseOrExamId, SpecFetcher,
17 chapters::{
18 self, Chapter, DatabaseChapter, course_chapters, get_chapter, get_chapter_by_page_id,
19 },
20 course_instances::{self, CourseInstance},
21 courses::{self, Course, CourseContextData, CourseMaterialCourse},
22 exercise_service_info::{self, ExerciseServiceInfoApi},
23 exercise_services::{get_internal_public_spec_url, get_model_solution_url},
24 exercise_slides::ExerciseSlide,
25 exercise_tasks::ExerciseTask,
26 exercises::Exercise,
27 organizations::Organization,
28 page_history::{self, HistoryChangeReason, PageHistoryContent},
29 peer_or_self_review_configs::CmsPeerOrSelfReviewConfig,
30 peer_or_self_review_questions::{
31 CmsPeerOrSelfReviewQuestion, normalize_cms_peer_or_self_review_questions,
32 },
33 prelude::*,
34 user_chapter_locking_statuses,
35 user_course_settings::{self, UserCourseSettings},
36 user_exercise_states,
37};
38
39#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
40
41pub struct Page {
42 pub id: Uuid,
43 pub created_at: DateTime<Utc>,
44 pub updated_at: DateTime<Utc>,
45 pub course_id: Option<Uuid>,
46 pub exam_id: Option<Uuid>,
47 pub chapter_id: Option<Uuid>,
48 pub url_path: String,
49 pub title: String,
50 pub deleted_at: Option<DateTime<Utc>>,
51 pub content: serde_json::Value,
53 pub order_number: i32,
54 pub copied_from: Option<Uuid>,
55 pub hidden: bool,
56 pub page_language_group_id: Option<Uuid>,
57}
58
59#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
60
61pub struct PageInfo {
62 pub page_id: Uuid,
63 pub page_title: String,
64 pub course_id: Option<Uuid>,
65 pub course_name: Option<String>,
66 pub course_slug: Option<String>,
67 pub organization_slug: Option<String>,
68}
69
70#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
71
72pub struct PageAudioFiles {
73 pub id: Uuid,
74 pub page_id: Uuid,
75 pub created_at: DateTime<Utc>,
76 pub deleted_at: Option<DateTime<Utc>>,
77 pub path: String,
78 pub mime_type: String,
79}
80
81impl Page {
82 pub fn blocks_cloned(&self) -> ModelResult<Vec<GutenbergBlock>> {
83 serde_json::from_value(self.content.clone()).map_err(Into::into)
84 }
85}
86
87#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, ToSchema)]
88#[serde(rename_all = "snake_case")]
89pub enum LockChapterContentState {
90 NotLocked,
91 WaitingTeacherReview,
92 Visible,
93}
94
95#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
96
97pub struct CoursePageWithUserData {
98 pub page: Page,
99 pub instance: Option<CourseInstance>,
100 pub settings: Option<UserCourseSettings>,
101 pub course: Option<CourseMaterialCourse>,
102 pub organization: Option<Organization>,
103 pub lock_chapter_content_state: Option<LockChapterContentState>,
104 pub was_redirected: bool,
106 pub is_test_mode: bool,
107}
108
109#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
110
111pub struct PageWithExercises {
112 #[serde(flatten)]
113 pub page: Page,
114 pub exercises: Vec<Exercise>,
115}
116
117#[derive(Default)]
118struct CourseMaterialPageContentFilterCache {
119 chapter_lock_content_states: HashMap<Uuid, LockChapterContentState>,
120 course_chapter_locking_enabled: HashMap<Uuid, bool>,
121}
122
123const URL_PATH_ENCODE_SET: &AsciiSet = &CONTROLS
124 .add(b' ')
125 .add(b'!')
126 .add(b'"')
127 .add(b'#')
128 .add(b'$')
129 .add(b'%')
130 .add(b'&')
131 .add(b'\'')
132 .add(b'(')
133 .add(b')')
134 .add(b'*')
135 .add(b'+')
136 .add(b',')
137 .add(b':')
138 .add(b';')
139 .add(b'<')
140 .add(b'=')
141 .add(b'>')
142 .add(b'?')
143 .add(b'@')
144 .add(b'[')
145 .add(b'\\')
146 .add(b']')
147 .add(b'^')
148 .add(b'`')
149 .add(b'{')
150 .add(b'|')
151 .add(b'}');
152
153fn normalize_url_path_for_storage(url_path: &str) -> String {
155 let decoded = percent_decode_str(url_path).decode_utf8_lossy();
156 utf8_percent_encode(decoded.trim(), URL_PATH_ENCODE_SET).to_string()
157}
158
159async fn get_lock_chapter_content_state_for_page(
160 conn: &mut PgConnection,
161 user_id: Option<Uuid>,
162 page: &Page,
163 cache: &mut CourseMaterialPageContentFilterCache,
164) -> ModelResult<Option<LockChapterContentState>> {
165 let Some(chapter_id) = page.chapter_id else {
166 return Ok(None);
167 };
168
169 if let Some(state) = cache.chapter_lock_content_states.get(&chapter_id).copied() {
170 return Ok(Some(state));
171 }
172
173 let state = match (user_id, page.course_id) {
174 (Some(user_id), Some(course_id)) => {
175 let course_chapter_locking_enabled = if let Some(enabled) = cache
176 .course_chapter_locking_enabled
177 .get(&course_id)
178 .copied()
179 {
180 enabled
181 } else {
182 let enabled = courses::get_course(conn, course_id)
183 .await?
184 .chapter_locking_enabled;
185 cache
186 .course_chapter_locking_enabled
187 .insert(course_id, enabled);
188 enabled
189 };
190
191 if !course_chapter_locking_enabled {
192 LockChapterContentState::NotLocked
193 } else {
194 let is_locked = matches!(
195 user_chapter_locking_statuses::get_or_init_status(
196 conn,
197 user_id,
198 chapter_id,
199 Some(course_id),
200 Some(course_chapter_locking_enabled),
201 )
202 .await?,
203 Some(user_chapter_locking_statuses::ChapterLockingStatus::CompletedAndLocked)
204 );
205
206 if !is_locked {
207 LockChapterContentState::NotLocked
208 } else if user_exercise_states::has_pending_manual_reviews_in_chapter(
209 conn, user_id, chapter_id,
210 )
211 .await?
212 {
213 LockChapterContentState::WaitingTeacherReview
214 } else {
215 LockChapterContentState::Visible
216 }
217 }
218 }
219 _ => LockChapterContentState::NotLocked,
220 };
221
222 cache.chapter_lock_content_states.insert(chapter_id, state);
223 Ok(Some(state))
224}
225
226async fn filter_course_material_page_with_cache(
227 conn: &mut PgConnection,
228 user_id: Option<Uuid>,
229 page: Page,
230 cache: &mut CourseMaterialPageContentFilterCache,
231) -> ModelResult<(Page, Option<LockChapterContentState>)> {
232 let mut blocks = match page.blocks_cloned() {
233 Ok(blocks) => blocks,
234 Err(e) => {
235 tracing::warn!(
236 "Failed to deserialize page content for page {}: {}. Falling back to empty blocks.",
237 page.id,
238 e
239 );
240 vec![]
241 }
242 };
243
244 blocks = replace_duplicate_client_ids(blocks);
245
246 let lock_chapter_content_state =
247 get_lock_chapter_content_state_for_page(conn, user_id, &page, cache).await?;
248 if let Some(lock_state) = lock_chapter_content_state {
249 blocks = filter_lock_chapter_blocks(
250 blocks,
251 matches!(lock_state, LockChapterContentState::Visible),
252 );
253 }
254
255 let mut filtered_page = page;
256 filtered_page.content = serde_json::to_value(blocks)?;
257 Ok((filtered_page, lock_chapter_content_state))
258}
259
260pub async fn filter_course_material_page(
261 conn: &mut PgConnection,
262 user_id: Option<Uuid>,
263 page: Page,
264) -> ModelResult<Page> {
265 let mut cache = CourseMaterialPageContentFilterCache::default();
266 Ok(
267 filter_course_material_page_with_cache(conn, user_id, page, &mut cache)
268 .await?
269 .0,
270 )
271}
272
273pub async fn filter_course_material_pages(
274 conn: &mut PgConnection,
275 user_id: Option<Uuid>,
276 pages: Vec<Page>,
277) -> ModelResult<Vec<Page>> {
278 let mut cache = CourseMaterialPageContentFilterCache::default();
279 let mut filtered_pages = Vec::with_capacity(pages.len());
280 for page in pages {
281 filtered_pages.push(
282 filter_course_material_page_with_cache(conn, user_id, page, &mut cache)
283 .await?
284 .0,
285 );
286 }
287 Ok(filtered_pages)
288}
289
290pub async fn filter_course_material_pages_with_exercises(
291 conn: &mut PgConnection,
292 user_id: Option<Uuid>,
293 pages_with_exercises: Vec<PageWithExercises>,
294) -> ModelResult<Vec<PageWithExercises>> {
295 let mut cache = CourseMaterialPageContentFilterCache::default();
296 let mut filtered_pages = Vec::with_capacity(pages_with_exercises.len());
297 for page_with_exercises in pages_with_exercises {
298 let (page, _) = filter_course_material_page_with_cache(
299 conn,
300 user_id,
301 page_with_exercises.page,
302 &mut cache,
303 )
304 .await?;
305 filtered_pages.push(PageWithExercises {
306 page,
307 exercises: page_with_exercises.exercises,
308 });
309 }
310 Ok(filtered_pages)
311}
312
313#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
315
316pub struct NewPage {
317 pub exercises: Vec<CmsPageExercise>,
318 pub exercise_slides: Vec<CmsPageExerciseSlide>,
319 pub exercise_tasks: Vec<CmsPageExerciseTask>,
320 pub content: Vec<GutenbergBlock>,
321 pub url_path: String,
322 pub title: String,
323 pub course_id: Option<Uuid>,
324 pub exam_id: Option<Uuid>,
325 pub chapter_id: Option<Uuid>,
326 pub front_page_of_chapter_id: Option<Uuid>,
328 pub content_search_language: Option<String>,
330}
331
332#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
334
335pub struct PageDetailsUpdate {
336 pub title: String,
337 pub url_path: String,
338}
339
340#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
341
342pub struct NormalizedCmsExerciseTask {
343 pub id: Uuid,
344 pub exercise_type: String,
345 pub assignment: serde_json::Value,
346 pub private_spec: Option<serde_json::Value>,
347}
348
349#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
350
351pub struct PageRoutingData {
352 pub url_path: String,
353 pub title: String,
354 pub page_id: Uuid,
355 pub chapter_number: i32,
356 pub chapter_id: Uuid,
357 pub chapter_opens_at: Option<DateTime<Utc>>,
358 pub chapter_front_page_id: Option<Uuid>,
359}
360
361#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
362pub struct PageMetadata {
363 pub page_id: Uuid,
364 pub order_number: i32,
365 pub chapter_id: Option<Uuid>,
366 pub chapter_number: Option<i32>,
367 pub course_id: Option<Uuid>,
368 pub exam_id: Option<Uuid>,
369}
370
371#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
372
373pub struct PageChapterAndCourseInformation {
374 pub chapter_name: Option<String>,
375 pub chapter_number: Option<i32>,
376 pub course_name: Option<String>,
377 pub course_slug: Option<String>,
378 pub chapter_front_page_id: Option<Uuid>,
379 pub chapter_front_page_url_path: Option<String>,
380 pub organization_slug: Option<String>,
381}
382
383#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
384
385pub struct PageSearchResult {
386 pub id: Uuid,
387 pub title_headline: Option<String>,
388 pub rank: Option<f32>,
389 pub content_headline: Option<String>,
390 pub url_path: String,
391 pub chapter_name: Option<String>,
392}
393
394#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
395
396pub struct ContentManagementPage {
397 pub page: Page,
398 pub exercises: Vec<CmsPageExercise>,
399 pub exercise_slides: Vec<CmsPageExerciseSlide>,
400 pub exercise_tasks: Vec<CmsPageExerciseTask>,
401 pub peer_or_self_review_configs: Vec<CmsPeerOrSelfReviewConfig>,
402 pub peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion>,
403 pub organization_id: Uuid,
404}
405
406#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
407
408pub struct SearchRequest {
409 pub query: String,
410}
411#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
412
413pub struct PageNavigationInformation {
414 pub chapter_front_page: Option<PageRoutingData>,
415 pub next_page: Option<PageRoutingData>,
416 pub previous_page: Option<PageRoutingData>,
417}
418
419#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
420
421pub struct ExerciseWithExerciseTasks {
422 id: Uuid,
423 created_at: DateTime<Utc>,
424 updated_at: DateTime<Utc>,
425 course_id: Uuid,
426 deleted_at: Option<DateTime<Utc>>,
427 name: String,
428 deadline: Option<DateTime<Utc>>,
429 page_id: Uuid,
430 exercise_tasks: Vec<ExerciseTask>,
431 score_maximum: i32,
432}
433
434#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
435
436pub struct IsChapterFrontPage {
437 pub is_chapter_front_page: bool,
438}
439
440#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
441
442pub struct HistoryRestoreData {
443 pub history_id: Uuid,
444}
445
446#[derive(Debug, Serialize, Deserialize)]
447pub struct NewCoursePage<'a> {
448 pub content: Vec<GutenbergBlock>,
449 pub course_id: Uuid,
450 pub order_number: i32,
451 pub title: &'a str,
452 pub hidden: bool,
453 pub url_path: &'a str,
454}
455
456impl<'a> NewCoursePage<'a> {
457 pub fn new(course_id: Uuid, order_number: i32, url_path: &'a str, title: &'a str) -> Self {
459 Self {
460 content: Default::default(),
461 course_id,
462 order_number,
463 title,
464 hidden: false,
465 url_path,
466 }
467 }
468
469 pub fn followed_by(&self, url_path: &'a str, title: &'a str) -> Self {
471 Self::new(self.course_id, self.order_number + 1, url_path, title)
472 }
473
474 pub fn set_content(mut self, content: Vec<GutenbergBlock>) -> Self {
476 self.content = content;
477 self
478 }
479
480 pub fn set_hidden(mut self, hidden: bool) -> Self {
482 self.hidden = hidden;
483 self
484 }
485}
486
487pub async fn insert_course_page(
488 conn: &mut PgConnection,
489 new_course_page: &NewCoursePage<'_>,
490 author: Uuid,
491) -> ModelResult<(Uuid, Uuid)> {
492 let course = crate::courses::get_course(&mut *conn, new_course_page.course_id).await?;
493 let page_language_group_id = crate::page_language_groups::insert(
494 &mut *conn,
495 crate::PKeyPolicy::Generate,
496 course.course_language_group_id,
497 )
498 .await?;
499
500 let mut tx = conn.begin().await?;
501 let page_res = sqlx::query!(
502 "
503INSERT INTO pages (
504 course_id,
505 content,
506 url_path,
507 title,
508 order_number,
509 hidden,
510 page_language_group_id
511 )
512VALUES ($1, $2, $3, $4, $5, $6, $7)
513RETURNING id
514",
515 new_course_page.course_id,
516 serde_json::to_value(new_course_page.content.clone())?,
517 new_course_page.url_path,
518 new_course_page.title,
519 new_course_page.order_number,
520 new_course_page.hidden,
521 page_language_group_id,
522 )
523 .fetch_one(&mut *tx)
524 .await?;
525 let history_id = crate::page_history::insert(
526 &mut tx,
527 PKeyPolicy::Generate,
528 page_res.id,
529 new_course_page.title,
530 &PageHistoryContent {
531 content: serde_json::Value::Array(vec![]),
532 exercises: vec![],
533 exercise_slides: vec![],
534 exercise_tasks: vec![],
535 peer_or_self_review_configs: Vec::new(),
536 peer_or_self_review_questions: Vec::new(),
537 },
538 HistoryChangeReason::PageSaved,
539 author,
540 None,
541 )
542 .await?;
543 tx.commit().await?;
544 Ok((page_res.id, history_id))
545}
546
547pub async fn insert_exam_page(
548 conn: &mut PgConnection,
549 exam_id: Uuid,
550 page: NewPage,
551 author: Uuid,
552) -> ModelResult<(Uuid, Uuid)> {
553 let mut tx = conn.begin().await?;
554 let page_res = sqlx::query!(
555 "
556INSERT INTO pages (
557 exam_id,
558 content,
559 url_path,
560 title,
561 order_number
562 )
563VALUES ($1, $2, $3, $4, $5)
564RETURNING id
565",
566 exam_id,
567 serde_json::Value::Array(vec![]),
568 page.url_path,
569 page.title,
570 0
571 )
572 .fetch_one(&mut *tx)
573 .await?;
574
575 let history_id = crate::page_history::insert(
576 &mut tx,
577 PKeyPolicy::Generate,
578 page_res.id,
579 page.title.as_str(),
580 &PageHistoryContent {
581 content: serde_json::Value::Array(vec![]),
582 exercises: vec![],
583 exercise_slides: vec![],
584 exercise_tasks: vec![],
585 peer_or_self_review_configs: Vec::new(),
586 peer_or_self_review_questions: Vec::new(),
587 },
588 HistoryChangeReason::PageSaved,
589 author,
590 None,
591 )
592 .await?;
593 tx.commit().await?;
594 Ok((page_res.id, history_id))
595}
596
597pub async fn set_chapter(
598 conn: &mut PgConnection,
599 page_id: Uuid,
600 chapter_id: Uuid,
601) -> ModelResult<()> {
602 sqlx::query!(
603 "UPDATE pages SET chapter_id = $1 WHERE id = $2",
604 chapter_id,
605 page_id
606 )
607 .execute(conn)
608 .await?;
609 Ok(())
610}
611
612pub async fn get_course_and_exam_id(
613 conn: &mut PgConnection,
614 id: Uuid,
615) -> ModelResult<CourseOrExamId> {
616 let res = sqlx::query!(
617 "
618SELECT course_id, exam_id
619FROM pages
620WHERE id = $1
621 AND deleted_at IS NULL;
622 ",
623 id
624 )
625 .fetch_one(conn)
626 .await?;
627 CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
628}
629
630#[derive(Copy, Clone, Debug, PartialEq, Eq)]
631pub enum PageVisibility {
632 Any,
633 Public,
634 Hidden,
635}
636
637impl PageVisibility {
638 fn get_inverse_visibility_filter(&self) -> Option<bool> {
673 match self {
674 PageVisibility::Any => None,
675 PageVisibility::Public => Some(true),
676 PageVisibility::Hidden => Some(false),
677 }
678 }
679}
680
681pub async fn get_all_by_course_id_and_visibility(
683 conn: &mut PgConnection,
684 course_id: Uuid,
685 page_visibility: PageVisibility,
686) -> ModelResult<Vec<Page>> {
687 let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
688 let res = sqlx::query_as!(
689 Page,
690 "
691SELECT id,
692 created_at,
693 updated_at,
694 course_id,
695 exam_id,
696 chapter_id,
697 url_path,
698 title,
699 deleted_at,
700 content,
701 order_number,
702 copied_from,
703 hidden,
704 page_language_group_id
705FROM pages
706WHERE course_id = $1
707 AND hidden IS DISTINCT FROM $2
708 AND deleted_at IS NULL
709 ",
710 course_id,
711 inverse_visibility_filter,
712 )
713 .fetch_all(conn)
714 .await?;
715 Ok(res)
716}
717
718pub async fn get_course_top_level_pages_by_course_id_and_visibility(
720 conn: &mut PgConnection,
721 course_id: Uuid,
722 page_visibility: PageVisibility,
723) -> ModelResult<Vec<Page>> {
724 let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
725 let pages = sqlx::query_as!(
726 Page,
727 "
728SELECT id,
729 created_at,
730 updated_at,
731 course_id,
732 exam_id,
733 chapter_id,
734 url_path,
735 title,
736 deleted_at,
737 content,
738 order_number,
739 copied_from,
740 hidden,
741 page_language_group_id
742FROM pages p
743WHERE course_id = $1
744 AND hidden IS DISTINCT FROM $2
745 AND p.chapter_id IS NULL
746 AND p.deleted_at IS NULL
747 ",
748 course_id,
749 inverse_visibility_filter,
750 )
751 .fetch_all(conn)
752 .await?;
753 Ok(pages)
754}
755
756pub async fn get_course_pages_by_chapter_id_and_visibility(
758 conn: &mut PgConnection,
759 chapter_id: Uuid,
760 page_visibility: PageVisibility,
761) -> ModelResult<Vec<Page>> {
762 let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
763 let res = sqlx::query_as!(
764 Page,
765 "
766SELECT id,
767 created_at,
768 updated_at,
769 course_id,
770 exam_id,
771 chapter_id,
772 url_path,
773 title,
774 deleted_at,
775 content,
776 order_number,
777 copied_from,
778 hidden,
779 page_language_group_id
780FROM pages
781WHERE chapter_id = $1
782 AND hidden IS DISTINCT FROM $2
783 AND deleted_at IS NULL
784 ",
785 chapter_id,
786 inverse_visibility_filter,
787 )
788 .fetch_all(conn)
789 .await?;
790 Ok(res)
791}
792
793pub async fn get_page(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<Page> {
794 let pages = sqlx::query_as!(
795 Page,
796 "
797SELECT id,
798 created_at,
799 updated_at,
800 course_id,
801 exam_id,
802 chapter_id,
803 url_path,
804 title,
805 deleted_at,
806 content,
807 order_number,
808 copied_from,
809 hidden,
810 page_language_group_id
811FROM pages
812WHERE id = $1;
813",
814 page_id
815 )
816 .fetch_one(conn)
817 .await?;
818 Ok(pages)
819}
820
821pub async fn get_front_page_by_chapter_id(
822 conn: &mut PgConnection,
823 chapter_id: Uuid,
824) -> ModelResult<Option<Page>> {
825 let page = sqlx::query_as!(
826 Page,
827 r#"
828SELECT p.id,
829 p.created_at,
830 p.updated_at,
831 p.course_id,
832 p.exam_id,
833 p.chapter_id,
834 p.url_path,
835 p.title,
836 p.deleted_at,
837 p.content,
838 p.order_number,
839 p.copied_from,
840 p.hidden,
841 p.page_language_group_id
842FROM chapters c
843 JOIN pages p ON p.id = c.front_page_id
844WHERE c.id = $1
845 AND c.deleted_at IS NULL
846 AND p.deleted_at IS NULL
847 "#,
848 chapter_id
849 )
850 .fetch_optional(conn)
851 .await?;
852 Ok(page)
853}
854
855pub async fn get_page_info(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<PageInfo> {
856 let res = sqlx::query_as!(
857 PageInfo,
858 r#"
859 SELECT
860 p.id as page_id,
861 p.title as page_title,
862 c.id as "course_id?",
863 c.name as "course_name?",
864 c.slug as "course_slug?",
865 o.slug as "organization_slug?"
866 FROM pages p
867 LEFT JOIN courses c
868 on c.id = p.course_id
869 LEFT JOIN organizations o
870 on o.id = c.organization_id
871 WHERE p.id = $1;
872 "#,
873 page_id
874 )
875 .fetch_one(conn)
876 .await?;
877
878 Ok(res)
879}
880
881async fn get_page_by_stored_path(
882 conn: &mut PgConnection,
883 course_id: Uuid,
884 url_path: &str,
885) -> ModelResult<Option<Page>> {
886 let page = sqlx::query_as!(
887 Page,
888 "
889SELECT pages.id,
890 pages.created_at,
891 pages.updated_at,
892 pages.course_id,
893 pages.exam_id,
894 pages.chapter_id,
895 pages.url_path,
896 pages.title,
897 pages.deleted_at,
898 pages.content,
899 pages.order_number,
900 pages.copied_from,
901 pages.hidden,
902 pages.page_language_group_id
903FROM pages
904WHERE pages.course_id = $1
905 AND url_path = $2
906 AND pages.deleted_at IS NULL;
907 ",
908 course_id,
909 url_path
910 )
911 .fetch_optional(conn)
912 .await?;
913 Ok(page)
914}
915
916pub async fn get_page_with_user_data_by_path(
917 conn: &mut PgConnection,
918 user_id: Option<Uuid>,
919 course_data: &CourseContextData,
920 url_path: &str,
921 file_store: &dyn FileStore,
922 app_conf: &ApplicationConfiguration,
923) -> ModelResult<CoursePageWithUserData> {
924 let normalized_url_path = normalize_url_path_for_storage(url_path);
925 let page_option = get_page_by_stored_path(conn, course_data.id, &normalized_url_path).await?;
926
927 if let Some(page) = page_option {
928 return get_course_page_with_user_data_from_selected_page(
929 conn,
930 user_id,
931 page,
932 false,
933 course_data.is_test_mode,
934 file_store,
935 app_conf,
936 )
937 .await;
938 } else {
939 let potential_redirect =
940 try_to_find_redirected_page_by_stored_path(conn, course_data.id, &normalized_url_path)
941 .await?;
942 if let Some(redirected_page) = potential_redirect {
943 return get_course_page_with_user_data_from_selected_page(
944 conn,
945 user_id,
946 redirected_page,
947 true,
948 course_data.is_test_mode,
949 file_store,
950 app_conf,
951 )
952 .await;
953 }
954 }
955
956 Err(model_err!(NotFound, "Page not found".to_string()))
957}
958
959pub async fn try_to_find_redirected_page(
960 conn: &mut PgConnection,
961 course_id: Uuid,
962 url_path: &str,
963) -> ModelResult<Option<Page>> {
964 let normalized_url_path = normalize_url_path_for_storage(url_path);
965 try_to_find_redirected_page_by_stored_path(conn, course_id, &normalized_url_path).await
966}
967
968async fn try_to_find_redirected_page_by_stored_path(
969 conn: &mut PgConnection,
970 course_id: Uuid,
971 url_path: &str,
972) -> ModelResult<Option<Page>> {
973 let page = sqlx::query_as!(
974 Page,
975 "
976SELECT pages.id,
977 pages.created_at,
978 pages.updated_at,
979 pages.course_id,
980 pages.exam_id,
981 pages.chapter_id,
982 pages.url_path,
983 pages.title,
984 pages.deleted_at,
985 pages.content,
986 pages.order_number,
987 pages.copied_from,
988 pages.hidden,
989 pages.page_language_group_id
990FROM url_redirections
991 JOIN pages on pages.id = url_redirections.destination_page_id
992WHERE url_redirections.course_id = $1
993 AND old_url_path = $2
994 AND url_redirections.deleted_at IS NULL
995 AND pages.deleted_at IS NULL;
996 ",
997 course_id,
998 url_path
999 )
1000 .fetch_optional(conn)
1001 .await?;
1002 Ok(page)
1003}
1004
1005pub async fn get_course_page_with_user_data_from_selected_page(
1006 conn: &mut PgConnection,
1007 user_id: Option<Uuid>,
1008 page: Page,
1009 was_redirected: bool,
1010 is_test_mode: bool,
1011 file_store: &dyn FileStore,
1012 app_conf: &ApplicationConfiguration,
1013) -> ModelResult<CoursePageWithUserData> {
1014 let mut content_filter_cache = CourseMaterialPageContentFilterCache::default();
1015 let (filtered_page, lock_chapter_content_state) =
1016 filter_course_material_page_with_cache(conn, user_id, page, &mut content_filter_cache)
1017 .await?;
1018
1019 if let Some(course_id) = filtered_page.course_id
1020 && let Some(user_id) = user_id
1021 {
1022 let instance =
1023 course_instances::current_course_instance_of_user(conn, user_id, course_id).await?;
1024 let settings =
1025 user_course_settings::get_user_course_settings_by_course_id(conn, user_id, course_id)
1026 .await?;
1027 let course = courses::get_course(conn, course_id).await?;
1028 let organization = Organization::from_database_organization(
1029 crate::organizations::get_organization(conn, course.organization_id).await?,
1030 file_store,
1031 app_conf,
1032 );
1033 return Ok(CoursePageWithUserData {
1034 page: filtered_page,
1035 instance,
1036 settings,
1037 course: Some(course.into()),
1038 was_redirected,
1039 is_test_mode,
1040 organization: Some(organization),
1041 lock_chapter_content_state,
1042 });
1043 }
1044 Ok(CoursePageWithUserData {
1045 page: filtered_page,
1046 instance: None,
1047 settings: None,
1048 course: None,
1049 was_redirected,
1050 is_test_mode,
1051 organization: None,
1052 lock_chapter_content_state,
1053 })
1054}
1055
1056pub async fn get_page_with_exercises(
1057 conn: &mut PgConnection,
1058 page_id: Uuid,
1059) -> ModelResult<ContentManagementPage> {
1060 let page = get_page(&mut *conn, page_id).await?;
1061
1062 let peer_or_self_review_configs =
1063 crate::peer_or_self_review_configs::get_peer_reviews_by_page_id(conn, page.id)
1064 .await?
1065 .into_iter()
1066 .flat_map(|pr| pr.exercise_id.map(|id| (id, pr)))
1067 .collect::<HashMap<_, _>>();
1068
1069 let peer_or_self_review_questions =
1070 crate::peer_or_self_review_questions::get_by_page_id(conn, page.id)
1071 .await?
1072 .into_iter()
1073 .into_group_map_by(|prq| prq.peer_or_self_review_config_id)
1074 .into_iter()
1075 .collect::<HashMap<_, _>>();
1076
1077 let exercises = crate::exercises::get_exercises_by_page_id(&mut *conn, page.id)
1078 .await?
1079 .into_iter()
1080 .map(|exercise| {
1081 let (a, b) = if exercise.needs_peer_review
1082 && exercise.use_course_default_peer_or_self_review_config
1083 {
1084 (None, None)
1085 } else {
1086 let peer_or_self_review_config =
1087 peer_or_self_review_configs.get(&exercise.id).cloned();
1088 let peer_or_self_review_questions = peer_or_self_review_config
1089 .as_ref()
1090 .and_then(|prc| peer_or_self_review_questions.get(&prc.id).cloned());
1091 (peer_or_self_review_config, peer_or_self_review_questions)
1092 };
1093
1094 Ok(CmsPageExercise::from_exercise_and_peer_review_data(
1095 exercise, a, b,
1096 ))
1097 })
1098 .collect::<ModelResult<Vec<_>>>()?;
1099
1100 let exercise_slides: Vec<CmsPageExerciseSlide> =
1101 crate::exercise_slides::get_exercise_slides_by_exercise_ids(
1102 &mut *conn,
1103 &exercises.iter().map(|x| x.id).collect::<Vec<_>>(),
1104 )
1105 .await?
1106 .into_iter()
1107 .map(|x| x.into())
1108 .collect();
1109
1110 let exercise_tasks: Vec<CmsPageExerciseTask> =
1111 crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_ids(
1112 &mut *conn,
1113 &exercise_slides.iter().map(|x| x.id).collect::<Vec<Uuid>>(),
1114 )
1115 .await?
1116 .into_iter()
1117 .map(|x| x.into())
1118 .collect();
1119
1120 let organization_id = get_organization_id(&mut *conn, page_id).await?;
1121 Ok(ContentManagementPage {
1122 page,
1123 exercises,
1124 exercise_slides,
1125 exercise_tasks,
1126 peer_or_self_review_configs: peer_or_self_review_configs.into_values().collect(),
1127 peer_or_self_review_questions: peer_or_self_review_questions
1128 .into_values()
1129 .flatten()
1130 .collect(),
1131 organization_id,
1132 })
1133}
1134
1135pub async fn get_by_exam_id(conn: &mut PgConnection, exam_id: Uuid) -> ModelResult<Page> {
1137 let res = sqlx::query_as!(
1138 Page,
1139 "
1140SELECT pages.id,
1141 pages.created_at,
1142 pages.updated_at,
1143 pages.course_id,
1144 pages.exam_id,
1145 pages.chapter_id,
1146 pages.url_path,
1147 pages.title,
1148 pages.deleted_at,
1149 pages.content,
1150 pages.order_number,
1151 pages.copied_from,
1152 pages.hidden,
1153 pages.page_language_group_id
1154FROM pages
1155WHERE exam_id = $1
1156AND pages.deleted_at IS NULL
1157",
1158 exam_id
1159 )
1160 .fetch_one(conn)
1161 .await?;
1162 Ok(res)
1163}
1164
1165pub async fn get_pages_by_course_id(
1166 conn: &mut PgConnection,
1167 course_id: Uuid,
1168) -> ModelResult<Vec<Page>> {
1169 let res = sqlx::query_as!(
1170 Page,
1171 "
1172SELECT pages.id,
1173 pages.created_at,
1174 pages.updated_at,
1175 pages.course_id,
1176 pages.exam_id,
1177 pages.chapter_id,
1178 pages.url_path,
1179 pages.title,
1180 pages.deleted_at,
1181 pages.content,
1182 pages.order_number,
1183 pages.copied_from,
1184 pages.hidden,
1185 pages.page_language_group_id
1186FROM pages
1187WHERE course_id = $1
1188AND pages.deleted_at IS NULL
1189",
1190 course_id
1191 )
1192 .fetch_all(conn)
1193 .await?;
1194 Ok(res)
1195}
1196
1197#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
1198
1199pub struct CmsPageExercise {
1200 pub id: Uuid,
1201 pub name: String,
1202 pub order_number: i32,
1203 pub score_maximum: i32,
1204 pub max_tries_per_slide: Option<i32>,
1205 pub limit_number_of_tries: bool,
1206 pub deadline: Option<DateTime<Utc>>,
1207 pub needs_peer_review: bool,
1208 pub needs_self_review: bool,
1209 pub peer_or_self_review_config: Option<CmsPeerOrSelfReviewConfig>,
1210 pub peer_or_self_review_questions: Option<Vec<CmsPeerOrSelfReviewQuestion>>,
1211 pub use_course_default_peer_or_self_review_config: bool,
1212 pub teacher_reviews_answer_after_locking: bool,
1213}
1214
1215impl CmsPageExercise {
1216 fn from_exercise_and_peer_review_data(
1217 exercise: Exercise,
1218 peer_or_self_review_config: Option<CmsPeerOrSelfReviewConfig>,
1219 peer_or_self_review_questions: Option<Vec<CmsPeerOrSelfReviewQuestion>>,
1220 ) -> Self {
1221 Self {
1222 id: exercise.id,
1223 name: exercise.name,
1224 order_number: exercise.order_number,
1225 score_maximum: exercise.score_maximum,
1226 max_tries_per_slide: exercise.max_tries_per_slide,
1227 limit_number_of_tries: exercise.limit_number_of_tries,
1228 deadline: exercise.deadline,
1229 needs_peer_review: exercise.needs_peer_review,
1230 needs_self_review: exercise.needs_self_review,
1231 use_course_default_peer_or_self_review_config: exercise
1232 .use_course_default_peer_or_self_review_config,
1233 teacher_reviews_answer_after_locking: exercise.teacher_reviews_answer_after_locking,
1234 peer_or_self_review_config,
1235 peer_or_self_review_questions,
1236 }
1237 }
1238}
1239
1240#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
1241
1242pub struct CmsPageExerciseSlide {
1243 pub id: Uuid,
1244 pub exercise_id: Uuid,
1245 pub order_number: i32,
1246}
1247
1248impl From<ExerciseSlide> for CmsPageExerciseSlide {
1249 fn from(slide: ExerciseSlide) -> Self {
1250 Self {
1251 id: slide.id,
1252 exercise_id: slide.exercise_id,
1253 order_number: slide.order_number,
1254 }
1255 }
1256}
1257
1258#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
1259
1260pub struct CmsPageExerciseTask {
1261 pub id: Uuid,
1262 pub exercise_slide_id: Uuid,
1263 pub assignment: serde_json::Value,
1264 pub exercise_type: String,
1265 pub private_spec: Option<serde_json::Value>,
1266 pub order_number: i32,
1267}
1268
1269impl From<ExerciseTask> for CmsPageExerciseTask {
1270 fn from(task: ExerciseTask) -> Self {
1271 CmsPageExerciseTask {
1272 id: task.id,
1273 exercise_slide_id: task.exercise_slide_id,
1274 assignment: task.assignment,
1275 exercise_type: task.exercise_type,
1276 private_spec: task.private_spec,
1277 order_number: task.order_number,
1278 }
1279 }
1280}
1281
1282#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
1283
1284pub struct CmsPageUpdate {
1285 pub content: Vec<GutenbergBlock>,
1286 pub exercises: Vec<CmsPageExercise>,
1287 pub exercise_slides: Vec<CmsPageExerciseSlide>,
1288 pub exercise_tasks: Vec<CmsPageExerciseTask>,
1289 pub url_path: String,
1290 pub title: String,
1291 pub chapter_id: Option<Uuid>,
1292}
1293
1294impl CmsPageUpdate {
1295 pub fn validate_exercise_data(&self) -> ModelResult<()> {
1297 let mut exercise_ids: HashMap<Uuid, bool> =
1298 self.exercises.iter().map(|x| (x.id, false)).collect();
1299 let mut slide_ids = self
1300 .exercise_slides
1301 .iter()
1302 .map(|x| {
1303 if let hash_map::Entry::Occupied(mut e) = exercise_ids.entry(x.exercise_id) {
1304 e.insert(true);
1305 Ok((x.id, false))
1306 } else {
1307 Err(model_err!(
1308 PreconditionFailed,
1309 "Exercide ids in slides don't match.".to_string()
1310 ))
1311 }
1312 })
1313 .collect::<ModelResult<HashMap<Uuid, bool>>>()?;
1314
1315 if let Some((exercise_id, _)) = exercise_ids.into_iter().find(|(_, x)| !x) {
1316 return Err(model_err!(
1317 PreconditionFailedWithCMSAnchorBlockId {
1318 id: exercise_id,
1319 description: "Exercise must have at least one slide.",
1320 },
1321 "Exercise must have at least one slide.".to_string()
1322 ));
1323 }
1324
1325 for task in self.exercise_tasks.iter() {
1326 if let hash_map::Entry::Occupied(mut e) = slide_ids.entry(task.exercise_slide_id) {
1327 e.insert(true);
1328 } else {
1329 return Err(model_err!(
1330 PreconditionFailed,
1331 "Exercise slide ids in tasks don't match.".to_string()
1332 ));
1333 }
1334 }
1335 if let Some((slide_id, _)) = slide_ids.into_iter().find(|(_, x)| !x) {
1336 return Err(model_err!(
1337 PreconditionFailedWithCMSAnchorBlockId {
1338 id: slide_id,
1339 description: "Exercise slide must have at least one task.",
1340 },
1341 "Exercise slide must have at least one task.".to_string()
1342 ));
1343 }
1344 Ok(())
1345 }
1346}
1347
1348#[derive(Debug, Clone)]
1349pub struct PageUpdateArgs {
1350 pub page_id: Uuid,
1351 pub author: Uuid,
1352 pub cms_page_update: CmsPageUpdate,
1353 pub retain_ids: bool,
1354 pub history_change_reason: HistoryChangeReason,
1355 pub is_exam_page: bool,
1356}
1357
1358pub async fn update_page(
1359 conn: &mut PgConnection,
1360 page_update: PageUpdateArgs,
1361 spec_fetcher: impl SpecFetcher,
1362 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
1363) -> ModelResult<ContentManagementPage> {
1364 let mut cms_page_update = page_update.cms_page_update;
1365 cms_page_update.validate_exercise_data()?;
1366
1367 for exercise in cms_page_update.exercises.iter_mut() {
1368 if let Some(peer_or_self_review_questions) = exercise.peer_or_self_review_questions.as_mut()
1369 {
1370 normalize_cms_peer_or_self_review_questions(peer_or_self_review_questions);
1371 }
1372 }
1373
1374 let content = replace_duplicate_client_ids(cms_page_update.content.clone());
1375 let normalized_url_path = normalize_url_path_for_storage(&cms_page_update.url_path);
1376
1377 if !page_update.is_exam_page
1378 && cms_page_update.chapter_id.is_none()
1379 && contains_blocks_not_allowed_in_top_level_pages(&content)
1380 {
1381 return Err(model_err!(Generic, "Top level non-exam pages cannot contain exercises, exercise tasks or list of exercises in the chapter".to_string()));
1382 }
1383
1384 let mut tx = conn.begin().await?;
1385
1386 let page = sqlx::query_as!(
1388 Page,
1389 r"
1390UPDATE pages
1391SET content = $2,
1392 url_path = $3,
1393 title = $4,
1394 chapter_id = $5
1395WHERE id = $1
1396RETURNING id,
1397 created_at,
1398 updated_at,
1399 course_id,
1400 exam_id,
1401 chapter_id,
1402 url_path,
1403 title,
1404 deleted_at,
1405 content,
1406 order_number,
1407 copied_from,
1408 pages.hidden,
1409 pages.page_language_group_id
1410 ",
1411 page_update.page_id,
1412 serde_json::to_value(&content)?,
1413 normalized_url_path,
1414 cms_page_update.title.trim(),
1415 cms_page_update.chapter_id
1416 )
1417 .fetch_one(&mut *tx)
1418 .await?;
1419
1420 let existing_exercise_ids =
1422 crate::exercises::delete_exercises_by_page_id(&mut tx, page.id).await?;
1423 let remapped_exercises = upsert_exercises(
1424 &mut tx,
1425 &page,
1426 &existing_exercise_ids,
1427 &cms_page_update.exercises,
1428 page_update.retain_ids,
1429 )
1430 .await?;
1431
1432 let existing_exercise_slide_ids =
1434 crate::exercise_slides::delete_exercise_slides_by_exercise_ids(
1435 &mut tx,
1436 &existing_exercise_ids,
1437 )
1438 .await?;
1439 let remapped_exercise_slides = upsert_exercise_slides(
1440 &mut tx,
1441 &remapped_exercises,
1442 &existing_exercise_slide_ids,
1443 &cms_page_update.exercise_slides,
1444 page_update.retain_ids,
1445 )
1446 .await?;
1447
1448 let existing_peer_or_self_review_config_ids =
1450 crate::peer_or_self_review_configs::delete_peer_reviews_by_exrcise_ids(
1451 &mut tx,
1452 &existing_exercise_ids,
1453 )
1454 .await?;
1455
1456 let (peer_or_self_review_configs, peer_or_self_review_questions) = cms_page_update
1457 .exercises
1458 .into_iter()
1459 .filter(|e| !e.use_course_default_peer_or_self_review_config)
1460 .flat_map(|e| {
1461 e.peer_or_self_review_config
1462 .zip(e.peer_or_self_review_questions)
1463 })
1464 .fold((vec![], vec![]), |(mut a, mut b), (pr, prq)| {
1465 a.push(pr);
1466 b.extend(prq);
1467 (a, b)
1468 });
1469
1470 let remapped_peer_or_self_review_configs = upsert_peer_or_self_review_configs(
1471 &mut tx,
1472 &existing_peer_or_self_review_config_ids,
1473 &peer_or_self_review_configs,
1474 &remapped_exercises,
1475 page_update.retain_ids,
1476 )
1477 .await?;
1478
1479 let existing_peer_or_self_review_questions =
1481 crate::peer_or_self_review_questions::delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
1482 &mut tx,
1483 &existing_peer_or_self_review_config_ids,
1484 )
1485 .await?;
1486
1487 let remapped_peer_or_self_review_questions = upsert_peer_or_self_review_questions(
1488 &mut tx,
1489 &existing_peer_or_self_review_questions,
1490 &peer_or_self_review_questions,
1491 &remapped_peer_or_self_review_configs,
1492 page_update.retain_ids,
1493 )
1494 .await?;
1495
1496 let existing_exercise_task_specs = sqlx::query_as!(
1498 ExerciseTaskIdAndSpec,
1499 "
1500UPDATE exercise_tasks
1501SET deleted_at = now()
1502WHERE exercise_slide_id = ANY($1)
1503AND deleted_at IS NULL
1504RETURNING id,
1505 private_spec,
1506 public_spec,
1507 model_solution_spec;
1508 ",
1509 &existing_exercise_slide_ids,
1510 )
1511 .fetch_all(&mut *tx)
1512 .await?;
1513 let final_tasks = upsert_exercise_tasks(
1514 &mut tx,
1515 &remapped_exercise_slides,
1516 &existing_exercise_task_specs,
1517 &cms_page_update.exercise_tasks,
1518 page_update.retain_ids,
1519 &spec_fetcher,
1520 fetch_service_info,
1521 )
1522 .await?;
1523
1524 let new_content = headless_lms_utils::document_schema_processor::remap_ids_in_content(
1526 &page.content,
1527 remapped_exercises
1528 .iter()
1529 .map(|(id, e)| (*id, e.id))
1530 .collect::<HashMap<Uuid, Uuid>>(),
1531 )?;
1532
1533 let page = sqlx::query_as!(
1534 Page,
1535 "
1536UPDATE pages
1537SET content = $1
1538WHERE id = $2
1539RETURNING id,
1540 created_at,
1541 updated_at,
1542 course_id,
1543 exam_id,
1544 chapter_id,
1545 url_path,
1546 title,
1547 deleted_at,
1548 content,
1549 order_number,
1550 copied_from,
1551 hidden,
1552 page_language_group_id
1553 ",
1554 new_content,
1555 page.id
1556 )
1557 .fetch_one(&mut *tx)
1558 .await?;
1559
1560 let x = remapped_exercises.into_values().collect::<Vec<_>>();
1561 let final_exercises = x
1562 .iter()
1563 .map(|e| {
1564 let peer_or_self_review_config = remapped_peer_or_self_review_configs
1565 .values()
1566 .find(|prc| prc.exercise_id == Some(e.id));
1567 if let Some(prc) = peer_or_self_review_config {
1568 let peer_or_self_review_questions = remapped_peer_or_self_review_questions
1569 .values()
1570 .filter(|prq| prq.peer_or_self_review_config_id == prc.id)
1571 .cloned()
1572 .collect::<Vec<_>>();
1573 return CmsPageExercise::from_exercise_and_peer_review_data(
1574 e.clone(),
1575 Some(prc.clone()),
1576 Some(peer_or_self_review_questions),
1577 );
1578 }
1579 CmsPageExercise::from_exercise_and_peer_review_data(e.clone(), None, None)
1580 })
1581 .collect();
1582 let final_slides: Vec<CmsPageExerciseSlide> = remapped_exercise_slides.into_values().collect();
1583 let final_peer_reviews: Vec<CmsPeerOrSelfReviewConfig> =
1584 remapped_peer_or_self_review_configs.into_values().collect();
1585 let final_peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion> =
1586 remapped_peer_or_self_review_questions
1587 .into_values()
1588 .collect();
1589 let history_content = PageHistoryContent {
1590 content: page.content.clone(),
1591 exercises: final_exercises,
1592 exercise_slides: final_slides,
1593 exercise_tasks: final_tasks,
1594 peer_or_self_review_configs: final_peer_reviews,
1595 peer_or_self_review_questions: final_peer_or_self_review_questions,
1596 };
1597 crate::page_history::insert(
1598 &mut tx,
1599 PKeyPolicy::Generate,
1600 page_update.page_id,
1601 &cms_page_update.title,
1602 &history_content,
1603 page_update.history_change_reason,
1604 page_update.author,
1605 None,
1606 )
1607 .await?;
1608 let organization_id = get_organization_id(&mut tx, page.id).await?;
1609
1610 tx.commit().await?;
1611
1612 Ok(ContentManagementPage {
1613 page,
1614 exercises: history_content.exercises,
1615 exercise_slides: history_content.exercise_slides,
1616 exercise_tasks: history_content.exercise_tasks,
1617 peer_or_self_review_configs: history_content.peer_or_self_review_configs,
1618 peer_or_self_review_questions: history_content.peer_or_self_review_questions,
1619 organization_id,
1620 })
1621}
1622
1623pub async fn update_by_id_in_parent_context(
1624 conn: &mut PgConnection,
1625 page_update: PageUpdateArgs,
1626 expected_course_id: Option<Uuid>,
1627 expected_exam_id: Option<Uuid>,
1628 spec_fetcher: impl SpecFetcher,
1629 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
1630) -> ModelResult<ContentManagementPage> {
1631 let mut tx = conn.begin().await?;
1632 sqlx::query!(
1633 r#"
1634SELECT p.id
1635FROM pages p
1636LEFT JOIN chapters c
1637 ON c.id = $4
1638WHERE p.id = $1
1639 AND p.deleted_at IS NULL
1640 AND (p.course_id = $2 OR p.exam_id = $3)
1641 AND (
1642 $4::uuid IS NULL
1643 OR (
1644 p.course_id IS NOT NULL
1645 AND c.id = $4
1646 AND c.course_id = p.course_id
1647 AND c.deleted_at IS NULL
1648 )
1649 )
1650 FOR UPDATE OF p
1651 "#,
1652 page_update.page_id,
1653 expected_course_id,
1654 expected_exam_id,
1655 page_update.cms_page_update.chapter_id
1656 )
1657 .fetch_one(&mut *tx)
1658 .await?;
1659
1660 let updated = update_page(&mut tx, page_update, spec_fetcher, fetch_service_info).await?;
1661 tx.commit().await?;
1662 Ok(updated)
1663}
1664
1665async fn upsert_exercises(
1667 conn: &mut PgConnection,
1668 page: &Page,
1669 existing_exercise_ids: &[Uuid],
1670 exercise_updates: &[CmsPageExercise],
1671 retain_exercise_ids: bool,
1672) -> ModelResult<HashMap<Uuid, Exercise>> {
1673 let mut remapped_exercises = HashMap::new();
1674 for exercise_update in exercise_updates.iter() {
1675 let exercise_exists = existing_exercise_ids.contains(&exercise_update.id);
1676 let safe_for_db_exercise_id = if retain_exercise_ids || exercise_exists {
1677 exercise_update.id
1678 } else {
1679 Uuid::new_v4()
1680 };
1681
1682 let db_exercise = crate::exercises::get_by_id(&mut *conn, safe_for_db_exercise_id)
1684 .await
1685 .optional()?;
1686
1687 let mut exercise_language_group_id = None;
1688
1689 if let Some(db_exercise) = db_exercise {
1690 exercise_language_group_id = db_exercise.exercise_language_group_id;
1691 }
1692 if let Some(course_id) = page.course_id {
1693 let course = crate::courses::get_course(&mut *conn, course_id).await?;
1694
1695 exercise_language_group_id = Some(
1696 crate::exercise_language_groups::insert(
1697 &mut *conn,
1698 PKeyPolicy::Generate,
1699 course.course_language_group_id,
1700 )
1701 .await?,
1702 );
1703 }
1704
1705 let exercise = sqlx::query_as!(
1706 Exercise,
1707 "
1708INSERT INTO exercises(
1709 id,
1710 course_id,
1711 name,
1712 order_number,
1713 page_id,
1714 chapter_id,
1715 exam_id,
1716 score_maximum,
1717 max_tries_per_slide,
1718 limit_number_of_tries,
1719 deadline,
1720 needs_peer_review,
1721 needs_self_review,
1722 use_course_default_peer_or_self_review_config,
1723 exercise_language_group_id,
1724 teacher_reviews_answer_after_locking
1725 )
1726VALUES (
1727 $1,
1728 $2,
1729 $3,
1730 $4,
1731 $5,
1732 $6,
1733 $7,
1734 $8,
1735 $9,
1736 $10,
1737 $11,
1738 $12,
1739 $13,
1740 $14,
1741 $15,
1742 $16
1743 ) ON CONFLICT (id) DO
1744UPDATE
1745SET course_id = $2,
1746 name = $3,
1747 order_number = $4,
1748 page_id = $5,
1749 chapter_id = $6,
1750 exam_id = $7,
1751 score_maximum = $8,
1752 max_tries_per_slide = $9,
1753 limit_number_of_tries = $10,
1754 deadline = $11,
1755 needs_peer_review = $12,
1756 needs_self_review = $13,
1757 use_course_default_peer_or_self_review_config = $14,
1758 exercise_language_group_id = $15,
1759 teacher_reviews_answer_after_locking = $16,
1760 deleted_at = NULL
1761RETURNING *;
1762 ",
1763 safe_for_db_exercise_id,
1764 page.course_id,
1765 exercise_update.name,
1766 exercise_update.order_number,
1767 page.id,
1768 page.chapter_id,
1769 page.exam_id,
1770 exercise_update.score_maximum,
1771 exercise_update.max_tries_per_slide,
1772 exercise_update.limit_number_of_tries,
1773 exercise_update.deadline,
1774 exercise_update.needs_peer_review,
1775 exercise_update.needs_self_review,
1776 exercise_update.use_course_default_peer_or_self_review_config,
1777 exercise_language_group_id,
1778 exercise_update.teacher_reviews_answer_after_locking,
1779 )
1780 .fetch_one(&mut *conn)
1781 .await?;
1782
1783 remapped_exercises.insert(exercise_update.id, exercise);
1784 }
1785 Ok(remapped_exercises)
1786}
1787
1788async fn upsert_exercise_slides(
1790 conn: &mut PgConnection,
1791 remapped_exercises: &HashMap<Uuid, Exercise>,
1792 existing_slide_ids: &[Uuid],
1793 slide_updates: &[CmsPageExerciseSlide],
1794 retain_exercise_ids: bool,
1795) -> ModelResult<HashMap<Uuid, CmsPageExerciseSlide>> {
1796 let mut remapped_exercise_slides = HashMap::new();
1797 for slide_update in slide_updates.iter() {
1798 let slide_exists = existing_slide_ids.contains(&slide_update.id);
1799 let safe_for_db_slide_id = if retain_exercise_ids || slide_exists {
1800 slide_update.id
1801 } else {
1802 Uuid::new_v4()
1803 };
1804 let safe_for_db_exercise_id = remapped_exercises
1805 .get(&slide_update.exercise_id)
1806 .ok_or_else(|| {
1807 model_err!(
1808 InvalidRequest,
1809 "Illegal exercise id for exercise slide.".to_string()
1810 )
1811 })?
1812 .id;
1813
1814 let exercise_slide = sqlx::query_as!(
1815 CmsPageExerciseSlide,
1816 "
1817INSERT INTO exercise_slides (id, exercise_id, order_number)
1818VALUES ($1, $2, $3) ON CONFLICT (id) DO
1819UPDATE
1820SET exercise_id = $2,
1821 order_number = $3,
1822 deleted_at = NULL
1823RETURNING id,
1824 exercise_id,
1825 order_number;
1826 ",
1827 safe_for_db_slide_id,
1828 safe_for_db_exercise_id,
1829 slide_update.order_number,
1830 )
1831 .fetch_one(&mut *conn)
1832 .await?;
1833
1834 remapped_exercise_slides.insert(slide_update.id, exercise_slide);
1835 }
1836 Ok(remapped_exercise_slides)
1837}
1838
1839async fn upsert_exercise_tasks(
1841 conn: &mut PgConnection,
1842 remapped_slides: &HashMap<Uuid, CmsPageExerciseSlide>,
1843 existing_task_specs: &[ExerciseTaskIdAndSpec],
1844 task_updates: &[CmsPageExerciseTask],
1845 retain_exercise_ids: bool,
1846 spec_fetcher: impl SpecFetcher,
1847 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
1848) -> ModelResult<Vec<CmsPageExerciseTask>> {
1849 let exercise_types: Vec<String> = task_updates
1851 .iter()
1852 .map(|task| task.exercise_type.clone())
1853 .unique()
1854 .collect();
1855 let exercise_service_hashmap = exercise_service_info::get_selected_exercise_services_by_type(
1856 &mut *conn,
1857 &exercise_types,
1858 fetch_service_info,
1859 )
1860 .await?;
1861 let public_spec_urls_by_exercise_type = exercise_service_hashmap
1862 .iter()
1863 .map(|(key, (service, info))| Ok((key, get_internal_public_spec_url(service, info)?)))
1864 .collect::<ModelResult<HashMap<&String, Url>>>()?;
1865 let model_solution_urls_by_exercise_type = exercise_service_hashmap
1866 .iter()
1867 .map(|(key, (service, info))| Ok((key, get_model_solution_url(service, info)?)))
1868 .collect::<ModelResult<HashMap<&String, Url>>>()?;
1869
1870 let mut remapped_exercise_tasks = Vec::new();
1871 for task_update in task_updates.iter() {
1872 let existing_exercise_task = existing_task_specs.iter().find(|o| o.id == task_update.id);
1873 let safe_for_db_exercise_task_id = match existing_exercise_task {
1874 Some(_) => task_update.id,
1875 _ if retain_exercise_ids => task_update.id,
1876 None => Uuid::new_v4(),
1877 };
1878
1879 let task_exists = existing_task_specs
1880 .iter()
1881 .any(|task| task.id == task_update.id);
1882 let safe_for_db_task_id = if retain_exercise_ids || task_exists {
1883 task_update.id
1884 } else {
1885 Uuid::new_v4()
1886 };
1887 let normalized_task = NormalizedCmsExerciseTask {
1888 id: safe_for_db_task_id,
1889 assignment: task_update.assignment.clone(),
1890 exercise_type: task_update.exercise_type.clone(),
1891 private_spec: task_update.private_spec.clone(),
1892 };
1893 let model_solution_spec = fetch_derived_spec(
1894 existing_exercise_task,
1895 &normalized_task,
1896 &model_solution_urls_by_exercise_type,
1897 &spec_fetcher,
1898 existing_exercise_task.and_then(|value| value.model_solution_spec.clone()),
1899 task_update.id,
1900 )
1901 .await?;
1902 let public_spec: Option<serde_json::Value> = fetch_derived_spec(
1903 existing_exercise_task,
1904 &normalized_task,
1905 &public_spec_urls_by_exercise_type,
1906 &spec_fetcher,
1907 existing_exercise_task.and_then(|value| value.public_spec.clone()),
1908 task_update.id,
1909 )
1910 .await?;
1911 let safe_for_db_exercise_slide_id = remapped_slides
1912 .get(&task_update.exercise_slide_id)
1913 .ok_or_else(|| {
1914 model_err!(
1915 InvalidRequest,
1916 "Illegal exercise slide id for exercise task.".to_string()
1917 )
1918 })?
1919 .id;
1920
1921 let exercise_task = sqlx::query_as!(
1923 CmsPageExerciseTask,
1924 "
1925INSERT INTO exercise_tasks(
1926 id,
1927 exercise_slide_id,
1928 exercise_type,
1929 assignment,
1930 public_spec,
1931 private_spec,
1932 model_solution_spec,
1933 order_number
1934 )
1935VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO
1936UPDATE
1937SET exercise_slide_id = $2,
1938 exercise_type = $3,
1939 assignment = $4,
1940 public_spec = $5,
1941 private_spec = $6,
1942 model_solution_spec = $7,
1943 order_number = $8,
1944 deleted_at = NULL
1945RETURNING id,
1946 exercise_slide_id,
1947 assignment,
1948 exercise_type,
1949 private_spec,
1950 order_number
1951 ",
1952 safe_for_db_exercise_task_id,
1953 safe_for_db_exercise_slide_id,
1954 task_update.exercise_type,
1955 task_update.assignment,
1956 public_spec,
1957 task_update.private_spec,
1958 model_solution_spec,
1959 task_update.order_number,
1960 )
1961 .fetch_one(&mut *conn)
1962 .await?;
1963 remapped_exercise_tasks.push(exercise_task)
1964 }
1965 Ok(remapped_exercise_tasks)
1966}
1967
1968pub async fn upsert_peer_or_self_review_configs(
1969 conn: &mut PgConnection,
1970 existing_peer_reviews: &[Uuid],
1971 peer_reviews: &[CmsPeerOrSelfReviewConfig],
1972 remapped_exercises: &HashMap<Uuid, Exercise>,
1973 retain_ids: bool,
1974) -> ModelResult<HashMap<Uuid, CmsPeerOrSelfReviewConfig>> {
1975 if peer_reviews.is_empty() {
1976 Ok(HashMap::new())
1977 } else {
1978 let mut new_peer_or_self_review_config_id_to_old_id = HashMap::new();
1979
1980 let mut sql: QueryBuilder<Postgres> = QueryBuilder::new(
1981 "INSERT INTO peer_or_self_review_configs (
1982 id,
1983 course_id,
1984 exercise_id,
1985 peer_reviews_to_give,
1986 peer_reviews_to_receive,
1987 processing_strategy,
1988 accepting_threshold,
1989 points_are_all_or_nothing,
1990 reset_answer_if_zero_points_from_review,
1991 review_instructions,
1992 deleted_at
1993 ) ",
1994 );
1995
1996 let mut illegal_exercise_id = None;
1998
1999 sql.push_values(peer_reviews.iter().take(1000), |mut x, pr| {
2000 let peer_review_exists = existing_peer_reviews.contains(&pr.id);
2001 let safe_for_db_peer_or_self_review_config_id = if retain_ids || peer_review_exists {
2002 pr.id
2003 } else {
2004 Uuid::new_v4()
2005 };
2006 new_peer_or_self_review_config_id_to_old_id
2007 .insert(safe_for_db_peer_or_self_review_config_id, pr.id);
2008
2009 let safe_for_db_exercise_id = pr.exercise_id.and_then(|id| {
2010 let res = remapped_exercises.get(&id).map(|e| e.id);
2011 if res.is_none() {
2012 error!("Illegal exercise id {:?}", id);
2013 illegal_exercise_id = Some(id);
2014 }
2015 res
2016 });
2017
2018 x.push_bind(safe_for_db_peer_or_self_review_config_id)
2019 .push_bind(pr.course_id)
2020 .push_bind(safe_for_db_exercise_id)
2021 .push_bind(pr.peer_reviews_to_give)
2022 .push_bind(pr.peer_reviews_to_receive)
2023 .push_bind(pr.processing_strategy)
2024 .push_bind(pr.accepting_threshold)
2025 .push_bind(pr.points_are_all_or_nothing)
2026 .push_bind(pr.reset_answer_if_zero_points_from_review)
2027 .push_bind(pr.review_instructions.clone())
2028 .push("NULL");
2029 });
2030
2031 if let Some(illegal_exercise_id) = illegal_exercise_id {
2032 return Err(model_err!(
2033 InvalidRequest,
2034 format!("Illegal exercise id {:?}", illegal_exercise_id)
2035 ));
2036 }
2037
2038 sql.push(
2039 " ON CONFLICT (id) DO
2040UPDATE
2041SET course_id = excluded.course_id,
2042 exercise_id = excluded.exercise_id,
2043 peer_reviews_to_give = excluded.peer_reviews_to_give,
2044 peer_reviews_to_receive = excluded.peer_reviews_to_receive,
2045 processing_strategy = excluded.processing_strategy,
2046 accepting_threshold = excluded.accepting_threshold,
2047 points_are_all_or_nothing = excluded.points_are_all_or_nothing,
2048 review_instructions = excluded.review_instructions,
2049 deleted_at = NULL
2050RETURNING id;
2051",
2052 );
2053
2054 let ids = sql
2055 .build()
2056 .fetch_all(&mut *conn)
2057 .await?
2058 .iter()
2059 .map(|x| x.get(0))
2060 .collect::<Vec<_>>();
2061
2062 let prs = sqlx::query_as!(
2063 CmsPeerOrSelfReviewConfig,
2064 r#"
2065SELECT id as "id!",
2066 course_id as "course_id!",
2067 exercise_id,
2068 peer_reviews_to_give as "peer_reviews_to_give!",
2069 peer_reviews_to_receive as "peer_reviews_to_receive!",
2070 processing_strategy AS "processing_strategy!",
2071 accepting_threshold "accepting_threshold!",
2072 points_are_all_or_nothing "points_are_all_or_nothing!",
2073 reset_answer_if_zero_points_from_review,
2074 review_instructions
2075FROM peer_or_self_review_configs
2076WHERE id IN (
2077 SELECT UNNEST($1::uuid [])
2078 )
2079 AND deleted_at IS NULL;
2080 "#,
2081 &ids
2082 )
2083 .fetch_all(&mut *conn)
2084 .await?;
2085
2086 let mut remapped_peer_reviews = HashMap::new();
2087
2088 for pr in prs {
2089 let old_id = new_peer_or_self_review_config_id_to_old_id
2090 .get(&pr.id)
2091 .ok_or_else(|| {
2092 model_err!(Generic, "Inserted peer reviews not found".to_string())
2093 })?;
2094 remapped_peer_reviews.insert(*old_id, pr);
2095 }
2096
2097 Ok(remapped_peer_reviews)
2098 }
2099}
2100
2101pub async fn upsert_peer_or_self_review_questions(
2102 conn: &mut PgConnection,
2103 existing_peer_or_self_review_questions: &[Uuid],
2104 peer_or_self_review_questions: &[CmsPeerOrSelfReviewQuestion],
2105 remapped_peer_or_self_review_config_ids: &HashMap<Uuid, CmsPeerOrSelfReviewConfig>,
2106 retain_ids: bool,
2107) -> ModelResult<HashMap<Uuid, CmsPeerOrSelfReviewQuestion>> {
2108 if peer_or_self_review_questions.is_empty() {
2109 Ok(HashMap::new())
2110 } else {
2111 let mut new_peer_or_self_review_question_id_to_old_id = HashMap::new();
2112
2113 let mut sql: QueryBuilder<Postgres> = QueryBuilder::new(
2114 "INSERT INTO peer_or_self_review_questions (
2115 id,
2116 peer_or_self_review_config_id,
2117 order_number,
2118 question,
2119 question_type,
2120 answer_required,
2121 weight,
2122 deleted_at
2123 ) ",
2124 );
2125
2126 let peer_or_self_review_questions = peer_or_self_review_questions
2127 .iter()
2128 .take(1000)
2129 .map(|prq| {
2130 remapped_peer_or_self_review_config_ids
2131 .get(&prq.peer_or_self_review_config_id)
2132 .map(|r| (prq, r.id))
2133 .ok_or_else(|| {
2134 model_err!(
2135 Generic,
2136 "No peer review found for peer review questions".to_string()
2137 )
2138 })
2139 })
2140 .collect::<Result<Vec<_>, _>>()?;
2141
2142 sql.push_values(
2143 peer_or_self_review_questions,
2144 |mut x, (prq, peer_or_self_review_config_id)| {
2145 let peer_review_question_exists =
2146 existing_peer_or_self_review_questions.contains(&prq.id);
2147 let safe_for_db_peer_or_self_review_question_id =
2148 if retain_ids || peer_review_question_exists {
2149 prq.id
2150 } else {
2151 Uuid::new_v4()
2152 };
2153 new_peer_or_self_review_question_id_to_old_id
2154 .insert(safe_for_db_peer_or_self_review_question_id, prq.id);
2155
2156 x.push_bind(safe_for_db_peer_or_self_review_question_id)
2157 .push_bind(peer_or_self_review_config_id)
2158 .push_bind(prq.order_number)
2159 .push_bind(prq.question.as_str())
2160 .push_bind(prq.question_type)
2161 .push_bind(prq.answer_required)
2162 .push_bind(prq.weight)
2163 .push("NULL");
2164 },
2165 );
2166
2167 sql.push(
2168 " ON CONFLICT (id) DO
2169UPDATE
2170SET peer_or_self_review_config_id = excluded.peer_or_self_review_config_id,
2171 order_number = excluded.order_number,
2172 question = excluded.question,
2173 question_type = excluded.question_type,
2174 answer_required = excluded.answer_required,
2175 weight = excluded.weight,
2176 deleted_at = NULL
2177RETURNING id;
2178",
2179 );
2180
2181 let ids = sql
2182 .build()
2183 .fetch_all(&mut *conn)
2184 .await?
2185 .iter()
2186 .map(|x| x.get(0))
2187 .collect::<Vec<_>>();
2188
2189 let prqs = sqlx::query_as!(
2190 CmsPeerOrSelfReviewQuestion,
2191 r#"
2192SELECT id AS "id!",
2193 answer_required AS "answer_required!",
2194 order_number AS "order_number!",
2195 peer_or_self_review_config_id AS "peer_or_self_review_config_id!",
2196 question AS "question!",
2197 question_type AS "question_type!",
2198 weight AS "weight!"
2199FROM peer_or_self_review_questions
2200WHERE id IN (
2201 SELECT UNNEST($1::uuid [])
2202 )
2203 AND deleted_at is null;
2204 "#,
2205 &ids
2206 )
2207 .fetch_all(&mut *conn)
2208 .await?;
2209
2210 let mut remapped_peer_or_self_review_questions = HashMap::new();
2211
2212 for prq in prqs {
2213 let old_id = new_peer_or_self_review_question_id_to_old_id
2214 .get(&prq.id)
2215 .ok_or_else(|| {
2216 model_err!(Generic, "Inserted peer reviews not found".to_string())
2217 })?;
2218 remapped_peer_or_self_review_questions.insert(*old_id, prq);
2219 }
2220
2221 Ok(remapped_peer_or_self_review_questions)
2222 }
2223}
2224
2225pub async fn update_page_content(
2227 conn: &mut PgConnection,
2228 page_id: Uuid,
2229 content: &serde_json::Value,
2230) -> ModelResult<()> {
2231 sqlx::query!(
2232 "
2233UPDATE pages
2234SET content = $1
2235WHERE id = $2;
2236",
2237 content,
2238 page_id
2239 )
2240 .execute(conn)
2241 .await?;
2242 Ok(())
2243}
2244
2245#[derive(Debug)]
2246struct ExerciseTaskIdAndSpec {
2247 pub id: Uuid,
2248 pub private_spec: Option<serde_json::Value>,
2249 pub public_spec: Option<serde_json::Value>,
2250 pub model_solution_spec: Option<serde_json::Value>,
2251}
2252
2253async fn fetch_derived_spec(
2254 existing_exercise_task: Option<&ExerciseTaskIdAndSpec>,
2255 task_update: &NormalizedCmsExerciseTask,
2256 urls_by_exercise_type: &HashMap<&String, Url>,
2257 spec_fetcher: impl SpecFetcher,
2258 previous_spec: Option<serde_json::Value>,
2259 cms_block_id: Uuid,
2260) -> Result<Option<serde_json::Value>, ModelError> {
2261 let result_spec: Option<serde_json::Value> = match existing_exercise_task {
2262 Some(exercise_task) if exercise_task.private_spec == task_update.private_spec => {
2263 previous_spec
2265 }
2266 _ => {
2267 let url = urls_by_exercise_type
2268 .get(&task_update.exercise_type)
2269 .ok_or_else(|| {
2270 model_err!(
2271 PreconditionFailedWithCMSAnchorBlockId {
2272 id: cms_block_id,
2273 description: "Missing exercise type for exercise task.",
2274 },
2275 "Missing exercise type for exercise task.".to_string()
2276 )
2277 })?
2278 .clone();
2279 let res = spec_fetcher(
2280 url,
2281 &task_update.exercise_type,
2282 task_update.private_spec.as_ref(),
2283 )
2284 .await?;
2285 Some(res)
2286 }
2287 };
2288 Ok(result_spec)
2289}
2290
2291pub async fn insert_new_content_page(
2292 conn: &mut PgConnection,
2293 new_page: NewPage,
2294 user: Uuid,
2295 spec_fetcher: impl SpecFetcher,
2296 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2297) -> ModelResult<Page> {
2298 let mut tx = conn.begin().await?;
2299
2300 let course_material_content = vec![GutenbergBlock::hero_section(new_page.title.trim(), "")];
2301
2302 let content_page = NewPage {
2303 chapter_id: new_page.chapter_id,
2304 content: course_material_content,
2305 course_id: new_page.course_id,
2306 exam_id: None,
2307 front_page_of_chapter_id: None,
2308 title: new_page.title,
2309 url_path: new_page.url_path,
2310 exercises: vec![],
2311 exercise_slides: vec![],
2312 exercise_tasks: vec![],
2313 content_search_language: None,
2314 };
2315 let page = crate::pages::insert_page(
2316 &mut tx,
2317 content_page,
2318 user,
2319 spec_fetcher,
2320 fetch_service_info,
2321 )
2322 .await?;
2323
2324 tx.commit().await?;
2325 Ok(page)
2326}
2327
2328pub async fn insert_page(
2329 conn: &mut PgConnection,
2330 new_page: NewPage,
2331 author: Uuid,
2332 spec_fetcher: impl SpecFetcher,
2333 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2334) -> ModelResult<Page> {
2335 let normalized_url_path = normalize_url_path_for_storage(&new_page.url_path);
2336 let mut page_language_group_id = None;
2337 if let Some(course_id) = new_page.course_id {
2338 let course = crate::courses::get_course(&mut *conn, course_id).await?;
2340 let new_language_group_id = crate::page_language_groups::insert(
2341 &mut *conn,
2342 crate::PKeyPolicy::Generate,
2343 course.course_language_group_id,
2344 )
2345 .await?;
2346 page_language_group_id = Some(new_language_group_id);
2347 }
2348
2349 let next_order_number = match (new_page.chapter_id, new_page.course_id) {
2350 (Some(id), _) => get_next_page_order_number_in_chapter(conn, id).await?,
2351 (None, Some(course_id)) => {
2352 get_next_order_number_for_courses_top_level_pages(conn, course_id).await?
2353 }
2354 (None, None) => 1,
2355 };
2356
2357 let course: OptionFuture<_> = new_page
2358 .course_id
2359 .map(|id| crate::courses::get_course(conn, id))
2360 .into();
2361 let course = course.await.transpose()?;
2362
2363 let content = replace_duplicate_client_ids(new_page.content.clone());
2364
2365 let mut tx = conn.begin().await?;
2366
2367 let content_search_language = course
2368 .and_then(|c| c.content_search_language)
2369 .or(new_page.content_search_language)
2370 .unwrap_or_else(|| "simple".to_string());
2371 let page = sqlx::query_as!(
2372 Page,
2373 r#"
2374INSERT INTO pages(
2375 course_id,
2376 exam_id,
2377 content,
2378 url_path,
2379 title,
2380 order_number,
2381 chapter_id,
2382 content_search_language,
2383 page_language_group_id
2384 )
2385VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)
2386RETURNING id,
2387 created_at,
2388 updated_at,
2389 course_id,
2390 exam_id,
2391 chapter_id,
2392 url_path,
2393 title,
2394 deleted_at,
2395 content,
2396 order_number,
2397 copied_from,
2398 pages.hidden,
2399 page_language_group_id
2400 "#,
2401 new_page.course_id,
2402 new_page.exam_id,
2403 serde_json::to_value(content)?,
2404 normalized_url_path,
2405 new_page.title.trim(),
2406 next_order_number,
2407 new_page.chapter_id,
2408 content_search_language as _,
2409 page_language_group_id,
2410 )
2411 .fetch_one(&mut *tx)
2412 .await?;
2413
2414 let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(page.content)?;
2415
2416 let cms_page = update_page(
2417 &mut tx,
2418 PageUpdateArgs {
2419 page_id: page.id,
2420 author,
2421 cms_page_update: CmsPageUpdate {
2422 content: parsed_content,
2423 exercises: new_page.exercises,
2424 exercise_slides: new_page.exercise_slides,
2425 exercise_tasks: new_page.exercise_tasks,
2426 url_path: page.url_path,
2427 title: page.title,
2428 chapter_id: page.chapter_id,
2429 },
2430 retain_ids: false,
2431 history_change_reason: HistoryChangeReason::PageSaved,
2432 is_exam_page: new_page.exam_id.is_some(),
2433 },
2434 spec_fetcher,
2435 fetch_service_info,
2436 )
2437 .await?;
2438
2439 if let Some(front_page_of_chapter_id) = new_page.front_page_of_chapter_id {
2440 let _res = sqlx::query_as!(
2441 DatabaseChapter,
2442 r#"
2443UPDATE chapters
2444SET front_page_id = $1
2445WHERE id = $2
2446RETURNING *;
2447 "#,
2448 page.id,
2449 front_page_of_chapter_id
2450 )
2451 .fetch_one(&mut *tx)
2453 .await?;
2454 }
2455
2456 tx.commit().await?;
2457 Ok(Page {
2458 content: cms_page.page.content,
2459 course_id: page.course_id,
2460 exam_id: page.exam_id,
2461 created_at: page.created_at,
2462 updated_at: page.updated_at,
2463 deleted_at: page.deleted_at,
2464 id: page.id,
2465 title: cms_page.page.title,
2466 url_path: cms_page.page.url_path,
2467 order_number: page.order_number,
2468 chapter_id: page.chapter_id,
2469 copied_from: page.copied_from,
2470 hidden: page.hidden,
2471 page_language_group_id: page.page_language_group_id,
2472 })
2473}
2474
2475pub async fn create_for_course_id(
2476 conn: &mut PgConnection,
2477 course_id: Uuid,
2478 new_page: NewPage,
2479 author: Uuid,
2480 spec_fetcher: impl SpecFetcher,
2481 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
2482) -> ModelResult<Page> {
2483 if new_page.course_id != Some(course_id) || new_page.exam_id.is_some() {
2484 return Err(model_err!(
2485 PreconditionFailed,
2486 "Page must be created for the expected course".to_string()
2487 ));
2488 }
2489
2490 let mut tx = conn.begin().await?;
2491
2492 if let Some(chapter_id) = new_page.chapter_id {
2493 let chapter = get_chapter(&mut tx, chapter_id).await?;
2494 if chapter.course_id != course_id {
2495 return Err(model_err!(
2496 PreconditionFailed,
2497 "Chapter must belong to the expected course".to_string()
2498 ));
2499 }
2500 }
2501
2502 if let Some(front_page_of_chapter_id) = new_page.front_page_of_chapter_id {
2503 let chapter = get_chapter(&mut tx, front_page_of_chapter_id).await?;
2504 if chapter.course_id != course_id {
2505 return Err(model_err!(
2506 PreconditionFailed,
2507 "Chapter must belong to the expected course".to_string()
2508 ));
2509 }
2510 if new_page.chapter_id.is_none() || new_page.chapter_id != new_page.front_page_of_chapter_id
2511 {
2512 return Err(model_err!(
2513 PreconditionFailed,
2514 "Page chapter_id must match front_page_of_chapter_id".to_string()
2515 ));
2516 }
2517 }
2518
2519 let mut new_page = new_page;
2520 if new_page.content.is_empty() {
2521 new_page.content = vec![GutenbergBlock::hero_section(new_page.title.trim(), "")];
2522 }
2523
2524 let page = insert_page(&mut tx, new_page, author, spec_fetcher, fetch_service_info).await?;
2525 tx.commit().await?;
2526 Ok(page)
2527}
2528
2529pub async fn delete_page_and_exercises(
2530 conn: &mut PgConnection,
2531 page_id: Uuid,
2532 author: Uuid,
2533) -> ModelResult<Page> {
2534 let mut tx = conn.begin().await?;
2535 let page = sqlx::query_as!(
2536 Page,
2537 r#"
2538UPDATE pages
2539SET deleted_at = now()
2540WHERE id = $1
2541AND deleted_at IS NULL
2542RETURNING id,
2543 created_at,
2544 updated_at,
2545 course_id,
2546 exam_id,
2547 chapter_id,
2548 url_path,
2549 title,
2550 deleted_at,
2551 content,
2552 order_number,
2553 copied_from,
2554 hidden,
2555 page_language_group_id
2556 "#,
2557 page_id,
2558 )
2559 .fetch_one(&mut *tx)
2560 .await?;
2561
2562 sqlx::query!(
2563 r#"
2564 UPDATE exercises
2565 SET deleted_at = now()
2566 WHERE page_id = $1
2567 AND deleted_at IS NULL
2568 "#,
2569 page_id,
2570 )
2571 .execute(&mut *tx)
2572 .await?;
2573
2574 sqlx::query!(
2575 "
2576UPDATE exercise_slides
2577SET deleted_at = now()
2578WHERE exercise_id IN (
2579 SELECT id
2580 FROM exercises
2581 WHERE page_id = $1
2582 )
2583 AND deleted_at IS NULL;
2584 ",
2585 page.id
2586 )
2587 .execute(&mut *tx)
2588 .await?;
2589
2590 sqlx::query!(
2591 r#"
2592UPDATE exercise_tasks
2593SET deleted_at = now()
2594WHERE exercise_slide_id IN (
2595 SELECT s.id
2596 FROM exercise_slides s
2597 JOIN exercises e ON (s.exercise_id = e.id)
2598 WHERE e.page_id = $1
2599 )
2600 AND deleted_at IS NULL;
2601 "#,
2602 page.id
2603 )
2604 .execute(&mut *tx)
2605 .await?;
2606
2607 let history_content = PageHistoryContent {
2608 content: json!(null),
2609 exercises: Vec::new(),
2610 exercise_slides: Vec::new(),
2611 exercise_tasks: Vec::new(),
2612 peer_or_self_review_configs: Vec::new(),
2613 peer_or_self_review_questions: Vec::new(),
2614 };
2615 crate::page_history::insert(
2616 &mut tx,
2617 PKeyPolicy::Generate,
2618 page.id,
2619 &page.title,
2620 &history_content,
2621 HistoryChangeReason::PageDeleted,
2622 author,
2623 None,
2624 )
2625 .await?;
2626
2627 tx.commit().await?;
2628 Ok(page)
2629}
2630
2631pub async fn get_chapters_pages_with_exercises(
2632 conn: &mut PgConnection,
2633 chapters_id: Uuid,
2634) -> ModelResult<Vec<PageWithExercises>> {
2635 let chapter_pages = sqlx::query_as!(
2636 Page,
2637 r#"
2638SELECT id,
2639 created_at,
2640 updated_at,
2641 course_id,
2642 exam_id,
2643 chapter_id,
2644 url_path,
2645 title,
2646 deleted_at,
2647 content,
2648 order_number,
2649 copied_from,
2650 hidden,
2651 page_language_group_id
2652FROM pages
2653WHERE chapter_id = $1
2654 AND deleted_at IS NULL
2655 "#,
2656 chapters_id
2657 )
2658 .fetch_all(&mut *conn)
2659 .await?;
2660 let page_ids: Vec<Uuid> = chapter_pages.iter().map(|page| page.id).collect();
2661 let pages_exercises = sqlx::query_as!(
2662 Exercise,
2663 r#"
2664SELECT *
2665FROM exercises
2666WHERE page_id IN (
2667 SELECT UNNEST($1::uuid [])
2668 )
2669 AND deleted_at IS NULL
2670 "#,
2671 &page_ids
2672 )
2673 .fetch_all(&mut *conn)
2674 .await?;
2675
2676 let mut page_to_exercises: HashMap<Uuid, Vec<Exercise>> = pages_exercises
2677 .into_iter()
2678 .into_group_map_by(|exercise| exercise.page_id);
2679 let mut chapter_pages_with_exercises: Vec<PageWithExercises> = chapter_pages
2680 .into_iter()
2681 .map(|page| {
2682 let page_id = page.id;
2683 let mut exercises = page_to_exercises.remove(&page_id).unwrap_or_default();
2684
2685 exercises.sort_by_key(|a| a.order_number);
2686 PageWithExercises { page, exercises }
2687 })
2688 .collect();
2689
2690 chapter_pages_with_exercises.sort_by_key(|a| a.page.order_number);
2691
2692 Ok(chapter_pages_with_exercises)
2693}
2694
2695pub async fn get_next_page(
2696 conn: &mut PgConnection,
2697 page_id: Uuid,
2698 visibility: PageVisibility,
2699) -> ModelResult<Option<PageRoutingData>> {
2700 let page_metadata = get_current_page_metadata(conn, page_id).await?;
2701 let next_page = get_next_page_by_order_number(conn, &page_metadata, visibility).await?;
2702
2703 match next_page {
2704 Some(next_page) => Ok(Some(next_page)),
2705 None => {
2706 let first_page =
2707 get_next_page_by_chapter_number(conn, &page_metadata, visibility).await?;
2708 Ok(first_page)
2709 }
2710 }
2711}
2712
2713async fn get_current_page_metadata(
2714 conn: &mut PgConnection,
2715 page_id: Uuid,
2716) -> ModelResult<PageMetadata> {
2717 let page_metadata = sqlx::query_as!(
2718 PageMetadata,
2719 r#"
2720SELECT p.id as page_id,
2721 p.order_number as order_number,
2722 p.course_id as course_id,
2723 p.exam_id as exam_id,
2724 c.id as "chapter_id?",
2725 c.chapter_number as "chapter_number?"
2726FROM pages p
2727 LEFT JOIN chapters c ON p.chapter_id = c.id
2728WHERE p.id = $1;
2729"#,
2730 page_id
2731 )
2732 .fetch_one(conn)
2733 .await?;
2734
2735 if page_metadata.chapter_number.is_none() {
2736 return Err(model_err!(
2737 InvalidRequest,
2738 "Page is not related to any chapter".to_string()
2739 ));
2740 }
2741
2742 Ok(page_metadata)
2743}
2744
2745async fn get_next_page_by_order_number(
2746 conn: &mut PgConnection,
2747 current_page_metadata: &PageMetadata,
2748 visibility: PageVisibility,
2749) -> ModelResult<Option<PageRoutingData>> {
2750 let inverse_visibility_filter = visibility.get_inverse_visibility_filter();
2751 let next_page = sqlx::query_as!(
2752 PageRoutingData,
2753 r#"
2754SELECT p.url_path as url_path,
2755 p.title as title,
2756 p.id as page_id,
2757 c.chapter_number as chapter_number,
2758 c.id as chapter_id,
2759 c.opens_at as chapter_opens_at,
2760 c.front_page_id as chapter_front_page_id
2761FROM pages p
2762 LEFT JOIN chapters c ON p.chapter_id = c.id
2763WHERE p.order_number = (
2764 SELECT MIN(pa.order_number)
2765 FROM pages pa
2766 WHERE pa.order_number > $1
2767 AND pa.deleted_at IS NULL
2768 AND pa.hidden IS DISTINCT FROM $4
2769 AND pa.course_id = p.course_id
2770 AND pa.chapter_id IS NOT DISTINCT FROM p.chapter_id
2771 )
2772 AND p.course_id = $2
2773 AND c.chapter_number = $3
2774 AND p.deleted_at IS NULL
2775 AND p.hidden IS DISTINCT FROM $4;
2776 "#,
2777 current_page_metadata.order_number,
2778 current_page_metadata.course_id,
2779 current_page_metadata.chapter_number,
2780 inverse_visibility_filter,
2781 )
2782 .fetch_optional(conn)
2783 .await?;
2784
2785 Ok(next_page)
2786}
2787
2788async fn get_next_page_by_chapter_number(
2789 conn: &mut PgConnection,
2790 current_page_metadata: &PageMetadata,
2791 visibility: PageVisibility,
2792) -> ModelResult<Option<PageRoutingData>> {
2793 let inverse_visibility_filter = visibility.get_inverse_visibility_filter();
2794 let next_page = sqlx::query_as!(
2795 PageRoutingData,
2796 r#"
2797SELECT p.url_path as url_path,
2798 p.title as title,
2799 p.id as page_id,
2800 c.chapter_number as chapter_number,
2801 c.id as chapter_id,
2802 c.opens_at as chapter_opens_at,
2803 c.front_page_id as chapter_front_page_id
2804FROM chapters c
2805 INNER JOIN pages p on c.id = p.chapter_id
2806WHERE c.chapter_number = (
2807 SELECT MIN(ca.chapter_number)
2808 FROM chapters ca
2809 WHERE ca.chapter_number > $1
2810 AND ca.deleted_at IS NULL
2811 )
2812 AND c.course_id = $2
2813 AND p.deleted_at IS NULL
2814 AND p.hidden IS DISTINCT FROM $3
2815ORDER BY p.order_number
2816LIMIT 1;
2817 "#,
2818 current_page_metadata.chapter_number,
2819 current_page_metadata.course_id,
2820 inverse_visibility_filter,
2821 )
2822 .fetch_optional(conn)
2823 .await?;
2824
2825 Ok(next_page)
2826}
2827
2828async fn get_next_page_order_number_in_chapter(
2829 conn: &mut PgConnection,
2830 chapter_id: Uuid,
2831) -> ModelResult<i32> {
2832 let next_order_number = sqlx::query!(
2833 "
2834select max(p.order_number) as order_number
2835from pages p
2836where p.chapter_id = $1
2837 and p.deleted_at is null;
2838",
2839 chapter_id
2840 )
2841 .fetch_one(conn)
2842 .await?;
2843
2844 match next_order_number.order_number {
2845 Some(order_number) => Ok(order_number + 1),
2846 None => Ok(0),
2847 }
2848}
2849
2850pub async fn get_page_navigation_data(
2851 conn: &mut PgConnection,
2852 page_id: Uuid,
2853 visibility: PageVisibility,
2854) -> ModelResult<PageNavigationInformation> {
2855 let previous_page_data = get_previous_page(conn, page_id, visibility).await?;
2856
2857 let next_page_data = get_next_page(conn, page_id, visibility).await?;
2858
2859 let chapter_front_page = get_chapter_front_page_by_page_id(conn, page_id).await?;
2860 let chapter_front_page_chapter = OptionFuture::from(
2862 chapter_front_page
2863 .clone()
2864 .and_then(|front_page| front_page.chapter_id)
2865 .map(|chapter_id| chapters::get_chapter(conn, chapter_id)),
2866 )
2867 .await
2868 .transpose()?;
2869
2870 let chapter_front_page = chapter_front_page
2871 .map(|front_page| -> ModelResult<_> {
2872 if let Some(chapter_front_page_chapter) = chapter_front_page_chapter {
2873 Ok(PageRoutingData {
2874 url_path: front_page.url_path,
2875 title: front_page.title,
2876 page_id: front_page.id,
2877 chapter_number: chapter_front_page_chapter.chapter_number,
2878 chapter_id: chapter_front_page_chapter.id,
2879 chapter_opens_at: chapter_front_page_chapter.opens_at,
2880 chapter_front_page_id: Some(front_page.id),
2881 })
2882 } else {
2883 Err(model_err!(
2884 InvalidRequest,
2885 "Chapter front page chapter not found".to_string()
2886 ))
2887 }
2888 })
2889 .transpose()?;
2890 Ok(PageNavigationInformation {
2891 chapter_front_page,
2892 next_page: next_page_data,
2893 previous_page: previous_page_data,
2894 })
2895}
2896
2897pub async fn get_previous_page(
2898 conn: &mut PgConnection,
2899 page_id: Uuid,
2900 visibility: PageVisibility,
2901) -> ModelResult<Option<PageRoutingData>> {
2902 let page_metadata = get_current_page_metadata(conn, page_id).await?;
2903 let previous_page = get_previous_page_by_order_number(conn, &page_metadata, visibility).await?;
2904
2905 match previous_page {
2906 Some(previous_page) => Ok(Some(previous_page)),
2907 None => {
2908 let first_page =
2909 get_previous_page_by_chapter_number(conn, &page_metadata, visibility).await?;
2910 Ok(first_page)
2911 }
2912 }
2913}
2914
2915pub async fn get_chapter_front_page_by_page_id(
2916 conn: &mut PgConnection,
2917 page_id: Uuid,
2918) -> ModelResult<Option<Page>> {
2919 let page_metadata = get_current_page_metadata(conn, page_id).await?;
2920 let chapter = chapters::get_chapter_info_by_page_metadata(conn, &page_metadata).await?;
2921 let page_option_future: OptionFuture<_> = chapter
2922 .chapter_front_page_id
2923 .map(|chapter_front_page_id| get_page(conn, chapter_front_page_id))
2924 .into();
2925 let page = page_option_future.await.transpose()?;
2926 Ok(page)
2927}
2928
2929async fn get_previous_page_by_order_number(
2930 conn: &mut PgConnection,
2931 current_page_metadata: &PageMetadata,
2932 visibility: PageVisibility,
2933) -> ModelResult<Option<PageRoutingData>> {
2934 let inverse_visibility_filter = visibility.get_inverse_visibility_filter();
2935 let previous_page = sqlx::query_as!(
2936 PageRoutingData,
2937 r#"
2938SELECT p.url_path as url_path,
2939 p.title as title,
2940 c.chapter_number as chapter_number,
2941 p.id as page_id,
2942 c.id as chapter_id,
2943 c.opens_at as chapter_opens_at,
2944 c.front_page_id as chapter_front_page_id
2945FROM pages p
2946 LEFT JOIN chapters c ON p.chapter_id = c.id
2947WHERE p.order_number = (
2948 SELECT MAX(pa.order_number)
2949 FROM pages pa
2950 WHERE pa.order_number < $1
2951 AND pa.deleted_at IS NULL
2952 AND pa.hidden IS DISTINCT FROM $4
2953 AND pa.course_id = p.course_id
2954 AND pa.chapter_id IS NOT DISTINCT FROM p.chapter_id
2955 )
2956 AND p.course_id = $2
2957 AND c.chapter_number = $3
2958 AND p.deleted_at IS NULL
2959 AND p.hidden IS DISTINCT FROM $4;
2960 "#,
2961 current_page_metadata.order_number,
2962 current_page_metadata.course_id,
2963 current_page_metadata.chapter_number,
2964 inverse_visibility_filter,
2965 )
2966 .fetch_optional(conn)
2967 .await?;
2968
2969 Ok(previous_page)
2970}
2971
2972async fn get_previous_page_by_chapter_number(
2973 conn: &mut PgConnection,
2974 current_page_metadata: &PageMetadata,
2975 visibility: PageVisibility,
2976) -> ModelResult<Option<PageRoutingData>> {
2977 let inverse_visibility_filter = visibility.get_inverse_visibility_filter();
2978 let previous_page = sqlx::query_as!(
2979 PageRoutingData,
2980 r#"
2981SELECT p.url_path AS url_path,
2982 p.title AS title,
2983 p.id AS page_id,
2984 c.chapter_number AS chapter_number,
2985 c.id AS chapter_id,
2986 c.opens_at AS chapter_opens_at,
2987 c.front_page_id AS chapter_front_page_id
2988FROM chapters c
2989 INNER JOIN pages p ON c.id = p.chapter_id
2990WHERE c.chapter_number = (
2991 SELECT MAX(ca.chapter_number)
2992 FROM chapters ca
2993 WHERE ca.chapter_number < $1
2994 AND ca.deleted_at IS NULL
2995 )
2996 AND c.course_id = $2
2997 AND p.deleted_at IS NULL
2998 AND p.hidden IS DISTINCT FROM $3
2999ORDER BY p.order_number DESC
3000LIMIT 1;
3001 "#,
3002 current_page_metadata.chapter_number,
3003 current_page_metadata.course_id,
3004 inverse_visibility_filter,
3005 )
3006 .fetch_optional(conn)
3007 .await?;
3008
3009 Ok(previous_page)
3010}
3011
3012async fn get_next_order_number_for_courses_top_level_pages(
3013 conn: &mut PgConnection,
3014 course_id: Uuid,
3015) -> ModelResult<i32> {
3016 let next_order_number = sqlx::query!(
3017 "
3018select max(p.order_number) as order_number
3019from pages p
3020where p.course_id = $1
3021 and p.chapter_id is null
3022 and p.deleted_at is null;
3023",
3024 course_id
3025 )
3026 .fetch_one(conn)
3027 .await?;
3028
3029 match next_order_number.order_number {
3030 Some(order_number) => Ok(order_number + 1),
3031 None => Ok(0),
3032 }
3033}
3034
3035pub async fn get_chapter_pages(
3036 conn: &mut PgConnection,
3037 chapter_id: Uuid,
3038) -> ModelResult<Vec<Page>> {
3039 let pages = sqlx::query_as!(
3040 Page,
3041 "
3042SELECT id,
3043 created_at,
3044 updated_at,
3045 course_id,
3046 exam_id,
3047 chapter_id,
3048 url_path,
3049 title,
3050 deleted_at,
3051 content,
3052 order_number,
3053 copied_from,
3054 hidden,
3055 page_language_group_id
3056FROM pages p
3057WHERE p.chapter_id = $1
3058 AND p.deleted_at IS NULL;
3059 ",
3060 chapter_id
3061 )
3062 .fetch_all(conn)
3063 .await?;
3064
3065 Ok(pages)
3066}
3067
3068pub async fn get_chapters_visible_pages_exclude_main_frontpage(
3069 conn: &mut PgConnection,
3070 chapter_id: Uuid,
3071) -> ModelResult<Vec<Page>> {
3072 let pages = sqlx::query_as!(
3073 Page,
3074 "
3075SELECT id,
3076 created_at,
3077 updated_at,
3078 course_id,
3079 exam_id,
3080 chapter_id,
3081 url_path,
3082 title,
3083 deleted_at,
3084 content,
3085 order_number,
3086 copied_from,
3087 hidden,
3088 page_language_group_id
3089FROM pages p
3090WHERE p.chapter_id = $1
3091 AND p.deleted_at IS NULL
3092 AND p.hidden IS FALSE
3093 AND p.url_path IS NOT NULL
3094 AND p.id NOT IN (
3095 SELECT front_page_id
3096 FROM chapters c
3097 WHERE c.front_page_id = p.id
3098 );
3099 ",
3100 chapter_id
3101 )
3102 .fetch_all(conn)
3103 .await?;
3104 Ok(pages)
3105}
3106
3107pub async fn get_page_search_results_for_phrase(
3111 conn: &mut PgConnection,
3112 course_id: Uuid,
3113 page_search_request: &SearchRequest,
3114) -> ModelResult<Vec<PageSearchResult>> {
3115 get_page_search_results(
3116 conn,
3117 course_id,
3118 page_search_request,
3119 PageSearchQueryType::Phrase,
3120 )
3121 .await
3122}
3123
3124pub async fn get_page_search_results_for_words(
3128 conn: &mut PgConnection,
3129 course_id: Uuid,
3130 page_search_request: &SearchRequest,
3131) -> ModelResult<Vec<PageSearchResult>> {
3132 get_page_search_results(
3133 conn,
3134 course_id,
3135 page_search_request,
3136 PageSearchQueryType::Words,
3137 )
3138 .await
3139}
3140
3141#[derive(Debug, sqlx::FromRow)]
3142struct RawPageSearchResult {
3143 id: Uuid,
3144 title_headline: Option<String>,
3145 rank: Option<f32>,
3146 url_path: String,
3147 chapter_name: Option<String>,
3148 content: Value,
3149}
3150
3151#[derive(Debug, sqlx::FromRow)]
3152struct SearchContentHeadlineRow {
3153 content_headline: Option<String>,
3154}
3155
3156#[derive(Clone, Copy)]
3157enum PageSearchQueryType {
3158 Phrase,
3159 Words,
3160}
3161
3162fn extract_searchable_text_from_content_value(content: &Value) -> String {
3163 let mut searchable_parts = Vec::new();
3164 collect_searchable_text_from_content_value(content, None, &mut searchable_parts);
3165 searchable_parts.join(" ")
3166}
3167
3168fn collect_searchable_text_from_content_value(
3169 value: &Value,
3170 current_key: Option<&str>,
3171 searchable_parts: &mut Vec<String>,
3172) {
3173 match value {
3174 Value::Object(map) => {
3175 for (key, value) in map {
3176 collect_searchable_text_from_content_value(value, Some(key), searchable_parts);
3177 }
3178 }
3179 Value::Array(values) => {
3180 for value in values {
3181 collect_searchable_text_from_content_value(value, None, searchable_parts);
3182 }
3183 }
3184 Value::String(value) if matches!(current_key, Some("content" | "title" | "subtitle")) => {
3185 searchable_parts.push(value.clone());
3186 }
3187 _ => {}
3188 }
3189}
3190
3191fn sanitized_searchable_text_for_public_page(content: &Value) -> String {
3192 let Ok(blocks) = serde_json::from_value::<Vec<GutenbergBlock>>(content.clone()) else {
3193 tracing::warn!(
3194 "Failed to deserialize page content for search snippet sanitization. Falling back to raw searchable text."
3195 );
3196 return extract_searchable_text_from_content_value(content);
3197 };
3198
3199 let filtered_blocks = filter_lock_chapter_blocks(blocks, false);
3200 match serde_json::to_value(filtered_blocks) {
3201 Ok(filtered_content) => extract_searchable_text_from_content_value(&filtered_content),
3202 Err(error) => {
3203 tracing::warn!(
3204 "Failed to serialize filtered page content for search snippet sanitization: {}. Falling back to raw searchable text.",
3205 error
3206 );
3207 extract_searchable_text_from_content_value(content)
3208 }
3209 }
3210}
3211
3212async fn get_page_search_results(
3213 conn: &mut PgConnection,
3214 course_id: Uuid,
3215 page_search_request: &SearchRequest,
3216 query_type: PageSearchQueryType,
3217) -> ModelResult<Vec<PageSearchResult>> {
3218 let course = crate::courses::get_course(&mut *conn, course_id).await?;
3219 let content_search_language = course
3220 .content_search_language
3221 .as_deref()
3222 .unwrap_or("simple");
3223
3224 let last_word = if let Some(last) = page_search_request
3227 .query
3228 .trim()
3229 .split_ascii_whitespace()
3230 .last()
3231 {
3232 last.to_string()
3233 } else {
3234 return Ok(Vec::new());
3235 };
3236
3237 let query_builder = match query_type {
3238 PageSearchQueryType::Phrase => "phraseto_tsquery",
3239 PageSearchQueryType::Words => "plainto_tsquery",
3240 };
3241 let query_builder_comment = match query_type {
3242 PageSearchQueryType::Phrase =>
3243 "-- Converts the search term to a phrase search with phraseto_tsquery but appends ':*' to the last word so that it
3244 -- becomes a prefix match. This way the search will also contain results when the last word in the search term
3245 -- is only partially typed. Note that if to_tsquery($4) decides to stem the word, the replacement will be skipped.",
3246 PageSearchQueryType::Words =>
3247 "-- Converts the search term to a word search with ands between the words with plainto_tsquery but appends ':*' to the
3248 -- last word so that it becomes a prefix match. This way the search will also contain results when the last word in
3249 -- the search term is only partially typed. Note that if to_tsquery($4) decides to stem the word, the replacement
3250 -- will be skipped.",
3251 };
3252
3253 let search_results_sql = format!(
3254 r#"
3255WITH cte as (
3256 {query_builder_comment}
3257 SELECT ts_rewrite(
3258 {query_builder}($2::regconfig, $3),
3259 to_tsquery($4),
3260 to_tsquery($4 || ':*')
3261 ) as query
3262)
3263SELECT p.id,
3264 ts_rank(
3265 p.content_search,
3266 (
3267 SELECT query
3268 from cte
3269 )
3270 ) as rank,
3271 ts_headline(
3272 $2::regconfig,
3273 p.title,
3274 (
3275 SELECT query
3276 from cte
3277 ),
3278 'MaxFragments=1, MaxWords=20, MinWords=1'
3279 ) as title_headline,
3280 COALESCE(p.url_path, '') as url_path,
3281 c.name as chapter_name,
3282 p.content
3283FROM pages p
3284LEFT JOIN chapters c ON p.chapter_id = c.id
3285WHERE p.course_id = $1
3286 AND p.deleted_at IS NULL
3287 AND p.hidden IS FALSE
3288 AND p.url_path IS NOT NULL
3289 AND p.content_search @@ (
3290 SELECT query
3291 from cte
3292 )
3293ORDER BY rank DESC
3294LIMIT 50;
3295 "#
3296 );
3297
3298 let raw_results = sqlx::query_as::<_, RawPageSearchResult>(AssertSqlSafe(search_results_sql))
3301 .bind(course_id)
3302 .bind(content_search_language)
3303 .bind(&page_search_request.query)
3304 .bind(&last_word)
3305 .fetch_all(&mut *conn)
3306 .await?;
3307
3308 let sanitized_search_texts = raw_results
3309 .iter()
3310 .map(|result| sanitized_searchable_text_for_public_page(&result.content))
3311 .collect::<Vec<_>>();
3312 let content_headlines = build_public_search_content_headlines(
3313 conn,
3314 content_search_language,
3315 &page_search_request.query,
3316 &last_word,
3317 query_type,
3318 &sanitized_search_texts,
3319 )
3320 .await?;
3321
3322 let search_results = raw_results
3323 .into_iter()
3324 .zip(content_headlines)
3325 .map(|(result, content_headline)| PageSearchResult {
3326 id: result.id,
3327 title_headline: result.title_headline,
3328 rank: result.rank,
3329 content_headline,
3330 url_path: result.url_path,
3331 chapter_name: result.chapter_name,
3332 })
3333 .collect::<Vec<_>>();
3334
3335 Ok(add_course_url_prefix_to_search_results(
3336 search_results,
3337 &course,
3338 ))
3339}
3340
3341async fn build_public_search_content_headlines(
3342 conn: &mut PgConnection,
3343 content_search_language: &str,
3344 query: &str,
3345 last_word: &str,
3346 query_type: PageSearchQueryType,
3347 sanitized_search_texts: &[String],
3348) -> ModelResult<Vec<Option<String>>> {
3349 if sanitized_search_texts.is_empty() {
3350 return Ok(Vec::new());
3351 }
3352
3353 let query_builder = match query_type {
3354 PageSearchQueryType::Phrase => "phraseto_tsquery",
3355 PageSearchQueryType::Words => "plainto_tsquery",
3356 };
3357 let query_builder_comment = match query_type {
3358 PageSearchQueryType::Phrase =>
3359 "-- Converts the search term to a phrase search with phraseto_tsquery but appends ':*' to the last word so that it
3360 -- becomes a prefix match. This way the search will also contain results when the last word in the search term
3361 -- is only partially typed. Note that if to_tsquery($3) decides to stem the word, the replacement will be skipped.",
3362 PageSearchQueryType::Words =>
3363 "-- Converts the search term to a word search with ands between the words with plainto_tsquery but appends ':*' to the
3364 -- last word so that it becomes a prefix match. This way the search will also contain results when the last word in
3365 -- the search term is only partially typed. Note that if to_tsquery($3) decides to stem the word, the replacement
3366 -- will be skipped.",
3367 };
3368
3369 let content_headline_sql = format!(
3370 r#"
3371WITH cte as (
3372 {query_builder_comment}
3373 SELECT ts_rewrite(
3374 {query_builder}($1::regconfig, $2),
3375 to_tsquery($3),
3376 to_tsquery($3 || ':*')
3377 ) as query
3378)
3379SELECT ts_headline(
3380 $1::regconfig,
3381 input.content,
3382 (
3383 SELECT query
3384 from cte
3385 ),
3386 'MaxFragments=1, MaxWords=30, MinWords=10'
3387) as content_headline
3388FROM unnest($4::text[]) WITH ORDINALITY AS input(content, ord)
3389ORDER BY ord;
3390 "#
3391 );
3392
3393 Ok(
3394 sqlx::query_as::<_, SearchContentHeadlineRow>(AssertSqlSafe(content_headline_sql))
3397 .bind(content_search_language)
3398 .bind(query)
3399 .bind(last_word)
3400 .bind(sanitized_search_texts.to_vec())
3401 .fetch_all(&mut *conn)
3402 .await?
3403 .into_iter()
3404 .map(|row| row.content_headline)
3405 .collect(),
3406 )
3407}
3408
3409fn add_course_url_prefix_to_search_results(
3410 search_results: Vec<PageSearchResult>,
3411 course: &Course,
3412) -> Vec<PageSearchResult> {
3413 search_results
3414 .into_iter()
3415 .map(|mut sr| {
3416 let optional_slash = if sr.url_path.starts_with('/') {
3417 ""
3418 } else {
3419 "/"
3420 };
3421 sr.url_path = format!("/{}{}{}", course.slug, optional_slash, sr.url_path);
3422 sr
3423 })
3424 .collect()
3425}
3426
3427pub async fn restore(
3429 conn: &mut PgConnection,
3430 page_id: Uuid,
3431 history_id: Uuid,
3432 author: Uuid,
3433 spec_fetcher: impl SpecFetcher,
3434 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
3435) -> ModelResult<Uuid> {
3436 let page = get_page(conn, page_id).await?;
3438 let history_data = page_history::get_history_data(conn, history_id).await?;
3439
3440 let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(history_data.content.content)?;
3441
3442 update_page(
3443 conn,
3444 PageUpdateArgs {
3445 page_id: page.id,
3446 author,
3447 cms_page_update: CmsPageUpdate {
3448 content: parsed_content,
3449 exercises: history_data.content.exercises,
3450 exercise_slides: history_data.content.exercise_slides,
3451 exercise_tasks: history_data.content.exercise_tasks,
3452 url_path: page.url_path,
3453 title: history_data.title,
3454 chapter_id: page.chapter_id,
3455 },
3456 retain_ids: true,
3457 history_change_reason: HistoryChangeReason::HistoryRestored,
3458 is_exam_page: history_data.exam_id.is_some(),
3459 },
3460 spec_fetcher,
3461 fetch_service_info,
3462 )
3463 .await?;
3464
3465 Ok(history_id)
3466}
3467
3468#[allow(clippy::too_many_arguments)]
3469pub async fn restore_from_history_for_page_id(
3470 conn: &mut PgConnection,
3471 page_id: Uuid,
3472 history_id: Uuid,
3473 authorized_source_page_id: Option<Uuid>,
3474 expected_course_id: Option<Uuid>,
3475 expected_exam_id: Option<Uuid>,
3476 author: Uuid,
3477 spec_fetcher: impl SpecFetcher,
3478 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
3479) -> ModelResult<Uuid> {
3480 let mut tx = conn.begin().await?;
3481 let validated_source_page_id = if let Some(source_page_id) = authorized_source_page_id {
3482 let source_page = sqlx::query!(
3483 r#"
3484SELECT p.id
3485FROM pages p
3486WHERE p.id = $1
3487 AND p.deleted_at IS NULL
3488 AND (p.course_id = $2 OR p.exam_id = $3)
3489FOR UPDATE
3490 "#,
3491 source_page_id,
3492 expected_course_id,
3493 expected_exam_id
3494 )
3495 .fetch_optional(&mut *tx)
3496 .await?;
3497 source_page.map(|row| row.id)
3498 } else {
3499 None
3500 };
3501
3502 sqlx::query!(
3503 r#"
3504SELECT p.id
3505FROM pages p
3506WHERE p.id = $1
3507 AND p.deleted_at IS NULL
3508 AND (p.course_id = $2 OR p.exam_id = $3)
3509FOR UPDATE
3510 "#,
3511 page_id,
3512 expected_course_id,
3513 expected_exam_id
3514 )
3515 .fetch_one(&mut *tx)
3516 .await?;
3517
3518 sqlx::query!(
3519 r#"
3520SELECT ph.id
3521FROM page_history ph
3522WHERE ph.id = $1
3523 AND ph.deleted_at IS NULL
3524 AND (
3525 ph.page_id = $2
3526 OR ph.page_id = $3
3527 )
3528FOR UPDATE
3529 "#,
3530 history_id,
3531 page_id,
3532 validated_source_page_id
3533 )
3534 .fetch_one(&mut *tx)
3535 .await?;
3536
3537 let restored = restore(
3538 &mut tx,
3539 page_id,
3540 history_id,
3541 author,
3542 spec_fetcher,
3543 fetch_service_info,
3544 )
3545 .await?;
3546 tx.commit().await?;
3547 Ok(restored)
3548}
3549
3550pub async fn get_organization_id(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<Uuid> {
3551 let res = sqlx::query!(
3552 "
3553SELECT organizations.id
3554FROM pages
3555 LEFT OUTER JOIN courses ON courses.id = pages.course_id
3556 LEFT OUTER JOIN exams ON exams.id = pages.exam_id
3557 JOIN organizations ON organizations.id = courses.organization_id
3558 OR organizations.id = exams.organization_id
3559WHERE pages.id = $1
3560",
3561 page_id,
3562 )
3563 .fetch_one(&mut *conn)
3564 .await?;
3565 Ok(res.id)
3566}
3567
3568pub async fn get_page_chapter_and_course_information(
3569 conn: &mut PgConnection,
3570 page_id: Uuid,
3571) -> ModelResult<PageChapterAndCourseInformation> {
3572 let res = sqlx::query_as!(
3573 PageChapterAndCourseInformation,
3574 r#"
3575SELECT chapters.name as "chapter_name?",
3576 chapters.chapter_number as "chapter_number?",
3577 courses.name as "course_name?",
3578 courses.slug as "course_slug?",
3579 chapters.front_page_id as "chapter_front_page_id?",
3580 p2.url_path as "chapter_front_page_url_path?",
3581 organizations.slug as "organization_slug?"
3582FROM pages
3583 LEFT JOIN chapters on pages.chapter_id = chapters.id
3584 LEFT JOIN courses on pages.course_id = courses.id
3585 LEFT JOIN pages p2 ON chapters.front_page_id = p2.id
3586 LEFT JOIN organizations on courses.organization_id = organizations.id
3587WHERE pages.id = $1
3588"#,
3589 page_id,
3590 )
3591 .fetch_one(&mut *conn)
3592 .await?;
3593 Ok(res)
3594}
3595
3596pub async fn get_page_by_course_id_and_language_group(
3597 conn: &mut PgConnection,
3598 course_id: Uuid,
3599 page_language_group_id: Uuid,
3600) -> ModelResult<Page> {
3601 let page = sqlx::query_as!(
3602 Page,
3603 "
3604SELECT id,
3605 created_at,
3606 updated_at,
3607 course_id,
3608 exam_id,
3609 chapter_id,
3610 url_path,
3611 title,
3612 deleted_at,
3613 content,
3614 order_number,
3615 copied_from,
3616 hidden,
3617 page_language_group_id
3618FROM pages p
3619WHERE p.course_id = $1
3620 AND p.page_language_group_id = $2
3621 AND p.deleted_at IS NULL
3622 ",
3623 course_id,
3624 page_language_group_id
3625 )
3626 .fetch_one(&mut *conn)
3627 .await?;
3628 Ok(page)
3629}
3630
3631pub async fn reorder_pages(
3635 conn: &mut PgConnection,
3636 pages: &[Page],
3637 course_id: Uuid,
3638) -> ModelResult<()> {
3639 let db_pages =
3640 get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
3641 let chapters = course_chapters(conn, course_id).await?;
3642
3643 let mut chapter_pages: HashMap<Option<Uuid>, Vec<&Page>> = HashMap::new();
3644
3645 for page in pages {
3646 chapter_pages.entry(page.chapter_id).or_default().push(page);
3647 }
3648
3649 let mut normalized_pages = Vec::with_capacity(pages.len());
3650
3651 for (_, chapter_pages) in chapter_pages.iter() {
3652 let mut sorted_pages = chapter_pages.to_vec();
3654 sorted_pages.sort_by(|a, b| {
3655 a.order_number
3656 .cmp(&b.order_number)
3657 .then_with(|| a.id.cmp(&b.id))
3658 });
3659
3660 for (idx, &page) in sorted_pages.iter().enumerate() {
3662 let mut normalized_page = page.clone();
3663 normalized_page.order_number = (idx as i32) + 1; normalized_pages.push(normalized_page);
3665 }
3666 }
3667
3668 let mut tx = conn.begin().await?;
3669
3670 sqlx::query!(
3674 "
3675UPDATE pages
3676SET order_number = floor(random() * (2000000 -200000 + 1) + 200000)
3677WHERE course_id = $1
3678 AND order_number != 0
3679 AND deleted_at IS NULL
3680 ",
3681 course_id
3682 )
3683 .execute(&mut *tx)
3684 .await?;
3685
3686 for page in normalized_pages {
3688 if let Some(matching_db_page) = db_pages.iter().find(|p| p.id == page.id) {
3689 if matching_db_page.chapter_id == page.chapter_id {
3690 sqlx::query!(
3692 "UPDATE pages SET order_number = $2 WHERE id = $1",
3693 page.id,
3694 page.order_number
3695 )
3696 .execute(&mut *tx)
3697 .await?;
3698 } else {
3699 if let Some(old_chapter_id) = matching_db_page.chapter_id {
3701 if let Some(new_chapter_id) = page.chapter_id {
3702 if let Some(old_chapter) = chapters.iter().find(|o| o.id == old_chapter_id)
3704 {
3705 if let Some(new_chapter) =
3706 chapters.iter().find(|o| o.id == new_chapter_id)
3707 {
3708 let old_path = &page.url_path;
3709 let new_path = old_path.replacen(
3710 &old_chapter.chapter_number.to_string(),
3711 &new_chapter.chapter_number.to_string(),
3712 1,
3713 );
3714 sqlx::query!(
3715 "UPDATE pages SET url_path = $2, chapter_id = $3, order_number = $4 WHERE pages.id = $1",
3716 page.id,
3717 new_path,
3718 new_chapter.id,
3719 page.order_number
3720 )
3721 .execute(&mut *tx)
3722 .await?;
3723 sqlx::query!(
3724 "INSERT INTO url_redirections(destination_page_id, old_url_path, course_id) VALUES ($1, $2, $3)",
3725 page.id,
3726 old_path,
3727 course_id
3728 )
3729 .execute(&mut *tx)
3730 .await?;
3731 } else {
3732 return Err(model_err!(
3733 InvalidRequest,
3734 "New chapter not found".to_string()
3735 ));
3736 }
3737 } else {
3738 return Err(model_err!(
3739 InvalidRequest,
3740 "Old chapter not found".to_string()
3741 ));
3742 }
3743 } else {
3744 return Err(model_err!(
3746 InvalidRequest,
3747 "Making a chapter page a top level page is not supported yet"
3748 .to_string()
3749 ));
3750 }
3751 } else {
3752 error!(
3753 "Cannot move a top level page to a chapter. matching_db_page.chapter_id: {:?} page.chapter_id: {:?}",
3754 matching_db_page.chapter_id, page.chapter_id
3755 );
3756 return Err(model_err!(
3758 InvalidRequest,
3759 "Moving a top level page to a chapter is not supported yet".to_string()
3760 ));
3761 }
3762 }
3763 } else {
3764 return Err(model_err!(
3765 InvalidRequest,
3766 format!("Page {} does exist in course {}", page.id, course_id)
3767 ));
3768 }
3769 }
3770 tx.commit().await?;
3771 Ok(())
3772}
3773
3774pub async fn reorder_chapters(
3775 conn: &mut PgConnection,
3776 chapters: &[Chapter],
3777 course_id: Uuid,
3778) -> ModelResult<()> {
3779 let db_chapters = course_chapters(conn, course_id).await?;
3780 let mut tx = conn.begin().await?;
3781 for chapter in chapters {
3785 if let Some(matching_db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id)
3786 && let Some(old_chapter) = db_chapters.iter().find(|o| o.id == matching_db_chapter.id)
3787 {
3788 sqlx::query!(
3791 "UPDATE chapters
3792 SET chapter_number = floor(random() * (20000000 - 2000000 + 1) + 200000)
3793 WHERE chapters.id = $1
3794 AND chapters.course_id = $2
3795 AND deleted_at IS NULL",
3796 matching_db_chapter.id,
3797 course_id
3798 )
3799 .execute(&mut *tx)
3800 .await?;
3801
3802 let chapter_with_randomized_chapter_number =
3804 get_chapter(&mut tx, matching_db_chapter.id).await?;
3805 let random_chapter_number = chapter_with_randomized_chapter_number.chapter_number;
3806 let pages =
3807 get_chapter_pages(&mut tx, chapter_with_randomized_chapter_number.id).await?;
3808
3809 for page in pages {
3810 let old_path = &page.url_path;
3811 let new_path = old_path.replacen(
3812 &old_chapter.chapter_number.to_string(),
3813 &random_chapter_number.to_string(),
3814 1,
3815 );
3816
3817 sqlx::query!(
3819 "UPDATE pages SET url_path = $2 WHERE pages.id = $1",
3820 page.id,
3821 new_path
3822 )
3823 .execute(&mut *tx)
3824 .await?;
3825 }
3826 }
3827 }
3828
3829 for chapter in chapters {
3830 if let Some(matching_db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id) {
3831 if let Some(new_chapter) = chapters.iter().find(|o| o.id == matching_db_chapter.id) {
3832 let new_chapter_number = &new_chapter.chapter_number;
3833
3834 let randomized_chapter = get_chapter(&mut tx, chapter.id).await?;
3835
3836 let randomized_chapter_number = randomized_chapter.chapter_number;
3837
3838 sqlx::query!(
3840 "UPDATE chapters SET chapter_number = $2 WHERE chapters.id = $1",
3841 chapter.id,
3842 new_chapter_number
3843 )
3844 .execute(&mut *tx)
3845 .await?;
3846
3847 let pages = get_chapter_pages(&mut tx, chapter.id).await?;
3849
3850 for page in pages {
3851 let path_with_temp_random_number = &page.url_path;
3852 let new_path = path_with_temp_random_number.replacen(
3853 &randomized_chapter_number.to_string(),
3854 &new_chapter_number.to_string(),
3855 1,
3856 );
3857 let old_path = path_with_temp_random_number.replacen(
3858 &randomized_chapter_number.to_string(),
3859 &chapter.chapter_number.to_string(),
3860 1,
3861 );
3862 sqlx::query!(
3864 "UPDATE pages SET url_path = $2 WHERE pages.id = $1",
3865 page.id,
3866 new_path
3867 )
3868 .execute(&mut *tx)
3869 .await?;
3870
3871 crate::url_redirections::upsert(
3872 &mut tx,
3873 PKeyPolicy::Generate,
3874 page.id,
3875 &old_path,
3876 course_id,
3877 )
3878 .await?;
3879 }
3880 } else {
3881 return Err(model_err!(
3882 InvalidRequest,
3883 "New chapter not found".to_string()
3884 ));
3885 }
3886 } else {
3887 return Err(model_err!(
3888 InvalidRequest,
3889 "Matching DB chapters not found".to_string()
3890 ));
3891 }
3892 }
3893
3894 tx.commit().await?;
3895 Ok(())
3896}
3897
3898pub async fn is_chapter_front_page(
3899 conn: &mut PgConnection,
3900 page_id: Uuid,
3901) -> ModelResult<IsChapterFrontPage> {
3902 let chapter = get_chapter_by_page_id(conn, page_id).await?;
3903
3904 Ok(chapter.front_page_id.map_or(
3905 IsChapterFrontPage {
3906 is_chapter_front_page: false,
3907 },
3908 |id| IsChapterFrontPage {
3909 is_chapter_front_page: id == page_id,
3910 },
3911 ))
3912}
3913
3914pub async fn update_page_details(
3915 conn: &mut PgConnection,
3916 page_id: Uuid,
3917 page_details_update: &PageDetailsUpdate,
3918) -> ModelResult<()> {
3919 let normalized_url_path = normalize_url_path_for_storage(&page_details_update.url_path);
3920 let mut tx = conn.begin().await?;
3921 let page_before_update = get_page(&mut tx, page_id).await?;
3922 sqlx::query!(
3923 "
3924UPDATE pages
3925SET title = $2,
3926 url_path = $3
3927WHERE id = $1
3928",
3929 page_id,
3930 page_details_update.title,
3931 normalized_url_path,
3932 )
3933 .execute(&mut *tx)
3934 .await?;
3935
3936 if let Some(course_id) = page_before_update.course_id
3937 && page_before_update.url_path != normalized_url_path
3938 {
3939 crate::url_redirections::upsert(
3941 &mut tx,
3942 PKeyPolicy::Generate,
3943 page_id,
3944 &page_before_update.url_path,
3945 course_id,
3946 )
3947 .await?;
3948 }
3949
3950 tx.commit().await?;
3951 Ok(())
3952}
3953
3954pub async fn get_by_ids_and_visibility(
3955 conn: &mut PgConnection,
3956 ids: &[Uuid],
3957 page_visibility: PageVisibility,
3958) -> ModelResult<Vec<Page>> {
3959 let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
3960 let pages = sqlx::query_as!(
3961 Page,
3962 "
3963SELECT id,
3964 created_at,
3965 updated_at,
3966 course_id,
3967 exam_id,
3968 chapter_id,
3969 url_path,
3970 title,
3971 deleted_at,
3972 content,
3973 order_number,
3974 copied_from,
3975 hidden,
3976 page_language_group_id
3977FROM pages
3978WHERE id = ANY($1)
3979 AND hidden IS DISTINCT FROM $2
3980 AND deleted_at IS NULL
3981 ",
3982 ids,
3983 inverse_visibility_filter
3984 )
3985 .fetch_all(conn)
3986 .await?;
3987 Ok(pages)
3988}
3989
3990pub async fn get_by_ids_deleted_and_visibility(
3991 conn: &mut PgConnection,
3992 ids: &[Uuid],
3993 page_visibility: PageVisibility,
3994) -> ModelResult<Vec<Page>> {
3995 let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
3996 let pages = sqlx::query_as!(
3997 Page,
3998 "
3999SELECT id,
4000 created_at,
4001 updated_at,
4002 course_id,
4003 exam_id,
4004 chapter_id,
4005 url_path,
4006 title,
4007 deleted_at,
4008 content,
4009 order_number,
4010 copied_from,
4011 hidden,
4012 page_language_group_id
4013FROM pages
4014WHERE id = ANY($1)
4015 AND hidden IS DISTINCT FROM $2
4016 AND deleted_at IS NOT NULL
4017 ",
4018 ids,
4019 inverse_visibility_filter
4020 )
4021 .fetch_all(conn)
4022 .await?;
4023 Ok(pages)
4024}
4025
4026#[cfg(test)]
4027mod test {
4028 use chrono::TimeZone;
4029
4030 use super::*;
4031 use crate::{exams::NewExam, test_helper::*};
4032
4033 #[test]
4034 fn normalizes_decoded_page_paths_for_storage_and_lookup() {
4035 assert_eq!(
4036 normalize_url_path_for_storage("/foo bar#part"),
4037 "/foo%20bar%23part"
4038 );
4039 assert_eq!(
4040 normalize_url_path_for_storage("/foo%20bar%23part"),
4041 "/foo%20bar%23part"
4042 );
4043 assert_eq!(
4044 normalize_url_path_for_storage(" /literal%percent "),
4045 "/literal%25percent"
4046 );
4047 assert_eq!(
4048 normalize_url_path_for_storage(" /literal%25percent "),
4049 "/literal%25percent"
4050 );
4051 }
4052
4053 #[tokio::test]
4054 async fn gets_organization_id() {
4055 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, :page);
4056
4057 let course_page_org = get_organization_id(tx.as_mut(), page).await.unwrap();
4058 assert_eq!(org, course_page_org);
4059
4060 let new_exam_id = crate::exams::insert(
4061 tx.as_mut(),
4062 PKeyPolicy::Generate,
4063 &NewExam {
4064 name: "name".to_string(),
4065 starts_at: None,
4066 ends_at: None,
4067 time_minutes: 120,
4068 organization_id: org,
4069 minimum_points_treshold: 24,
4070 grade_manually: false,
4071 },
4072 )
4073 .await
4074 .unwrap();
4075 let page = crate::pages::insert_page(
4076 tx.as_mut(),
4077 NewPage {
4078 exercises: vec![],
4079 exercise_slides: vec![],
4080 exercise_tasks: vec![],
4081 content: vec![],
4082 url_path: "url".to_string(),
4083 title: "title".to_string(),
4084 course_id: None,
4085 exam_id: Some(new_exam_id),
4086 chapter_id: None,
4087 front_page_of_chapter_id: None,
4088 content_search_language: None,
4089 },
4090 user,
4091 |_, _, _| unimplemented!(),
4092 |_| unimplemented!(),
4093 )
4094 .await
4095 .unwrap();
4096 let exam_page_org = get_organization_id(tx.as_mut(), page.id).await.unwrap();
4097 assert_eq!(org, exam_page_org);
4098 }
4099
4100 #[tokio::test]
4101 async fn page_update_validation_works() {
4102 let e1 = CmsPageExercise {
4103 id: Uuid::parse_str("0c9dca80-5904-4d35-a945-8c080446f667").unwrap(),
4104 name: "".to_string(),
4105 order_number: 1,
4106 score_maximum: 1,
4107 max_tries_per_slide: None,
4108 limit_number_of_tries: false,
4109 deadline: Some(Utc.with_ymd_and_hms(2125, 1, 1, 23, 59, 59).unwrap()),
4110 needs_peer_review: false,
4111 needs_self_review: false,
4112 peer_or_self_review_config: None,
4113 peer_or_self_review_questions: None,
4114 use_course_default_peer_or_self_review_config: false,
4115 teacher_reviews_answer_after_locking: true,
4116 };
4117 let e1_s1 = CmsPageExerciseSlide {
4118 id: Uuid::parse_str("43380e81-6ff2-4f46-9f38-af0ac6a8421a").unwrap(),
4119 exercise_id: e1.id,
4120 order_number: 1,
4121 };
4122 let e1_s1_t1 = CmsPageExerciseTask {
4123 id: Uuid::parse_str("6fb19c22-bca0-42cf-8be5-4141e21cc7a9").unwrap(),
4124 exercise_slide_id: e1_s1.id,
4125 assignment: serde_json::json!([]),
4126 exercise_type: "exercise".to_string(),
4127 private_spec: None,
4128 order_number: 1,
4129 };
4130
4131 assert!(
4133 create_update(vec![], vec![], vec![])
4134 .validate_exercise_data()
4135 .is_ok()
4136 );
4137
4138 assert!(
4140 create_update(
4141 vec![e1.clone()],
4142 vec![e1_s1.clone()],
4143 vec![e1_s1_t1.clone()],
4144 )
4145 .validate_exercise_data()
4146 .is_ok()
4147 );
4148
4149 assert!(
4151 create_update(vec![e1.clone()], vec![], vec![e1_s1_t1],)
4152 .validate_exercise_data()
4153 .is_err()
4154 );
4155
4156 assert!(
4158 create_update(vec![e1], vec![e1_s1], vec![],)
4159 .validate_exercise_data()
4160 .is_err()
4161 );
4162 }
4163
4164 fn create_update(
4165 exercises: Vec<CmsPageExercise>,
4166 exercise_slides: Vec<CmsPageExerciseSlide>,
4167 exercise_tasks: Vec<CmsPageExerciseTask>,
4168 ) -> CmsPageUpdate {
4169 CmsPageUpdate {
4170 content: vec![],
4171 exercises,
4172 exercise_slides,
4173 exercise_tasks,
4174 url_path: "".to_string(),
4175 title: "".to_string(),
4176 chapter_id: None,
4177 }
4178 }
4179
4180 fn lock_chapter_test_block(hidden_text: &str) -> GutenbergBlock {
4181 GutenbergBlock::block_with_name_attributes_and_inner_blocks(
4182 "moocfi/lock-chapter",
4183 headless_lms_utils::attributes! {},
4184 vec![GutenbergBlock::paragraph(hidden_text)],
4185 )
4186 }
4187
4188 fn first_lock_chapter_inner_block_count(page: &Page) -> usize {
4189 page.blocks_cloned().unwrap()[0].inner_blocks.len()
4190 }
4191
4192 async fn set_course_chapter_locking_enabled(
4194 tx: &mut PgConnection,
4195 course_id: Uuid,
4196 chapter_locking_enabled: bool,
4197 ) {
4198 let course_before_update = courses::get_course(tx, course_id).await.unwrap();
4199 courses::update_course(
4200 tx,
4201 course_id,
4202 courses::CourseUpdate {
4203 chapter_locking_enabled,
4204 name: course_before_update.name,
4205 description: course_before_update.description,
4206 is_draft: course_before_update.is_draft,
4207 is_test_mode: course_before_update.is_test_mode,
4208 can_add_chatbot: course_before_update.can_add_chatbot,
4209 is_unlisted: course_before_update.is_unlisted,
4210 is_joinable_by_code_only: course_before_update.is_joinable_by_code_only,
4211 ask_marketing_consent: course_before_update.ask_marketing_consent,
4212 flagged_answers_threshold: course_before_update
4213 .flagged_answers_threshold
4214 .unwrap_or_default(),
4215 flagged_answers_skip_manual_review_and_allow_retry: course_before_update
4216 .flagged_answers_skip_manual_review_and_allow_retry,
4217 closed_at: course_before_update.closed_at,
4218 closed_additional_message: course_before_update.closed_additional_message,
4219 closed_course_successor_id: course_before_update.closed_course_successor_id,
4220 ai_policy: course_before_update.ai_policy,
4221 course_material_ai_instructions: course_before_update
4222 .course_material_ai_instructions,
4223 },
4224 )
4225 .await
4226 .unwrap();
4227 }
4228
4229 #[test]
4230 fn sanitized_searchable_text_for_public_page_hides_lock_chapter_inner_blocks() {
4231 let content = serde_json::to_value(vec![
4232 GutenbergBlock::paragraph("Visible introduction"),
4233 lock_chapter_test_block("Model solution"),
4234 ])
4235 .unwrap();
4236
4237 let searchable_text = sanitized_searchable_text_for_public_page(&content);
4238
4239 assert!(searchable_text.contains("Visible introduction"));
4240 assert!(!searchable_text.contains("Model solution"));
4241 }
4242
4243 #[tokio::test]
4244 async fn filter_course_material_pages_hides_lock_chapter_inner_blocks_until_reviews_complete() {
4245 insert_data!(
4246 :tx,
4247 :user,
4248 :org,
4249 :course,
4250 instance: _instance,
4251 :course_module,
4252 chapter: chapter_id,
4253 page: page_id,
4254 exercise: exercise_id,
4255 slide: _exercise_slide_id,
4256 task: _exercise_task_id
4257 );
4258 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
4259
4260 update_page_content(
4261 tx.as_mut(),
4262 page_id,
4263 &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
4264 )
4265 .await
4266 .unwrap();
4267
4268 crate::exercises::update_teacher_reviews_answer_after_locking(
4269 tx.as_mut(),
4270 exercise_id,
4271 true,
4272 )
4273 .await
4274 .unwrap();
4275 crate::user_exercise_states::get_or_create_user_exercise_state(
4276 tx.as_mut(),
4277 user,
4278 exercise_id,
4279 Some(course),
4280 None,
4281 )
4282 .await
4283 .unwrap();
4284 crate::user_chapter_locking_statuses::complete_and_lock_chapter(
4285 tx.as_mut(),
4286 user,
4287 chapter_id,
4288 course,
4289 )
4290 .await
4291 .unwrap();
4292 crate::user_exercise_states::update_reviewing_stage(
4293 tx.as_mut(),
4294 user,
4295 CourseOrExamId::Course(course),
4296 exercise_id,
4297 crate::user_exercise_states::ReviewingStage::WaitingForManualGrading,
4298 )
4299 .await
4300 .unwrap();
4301
4302 let page = get_page(tx.as_mut(), page_id).await.unwrap();
4303 let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
4304 .await
4305 .unwrap();
4306 assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 0);
4307
4308 crate::user_exercise_states::update_reviewing_stage(
4309 tx.as_mut(),
4310 user,
4311 CourseOrExamId::Course(course),
4312 exercise_id,
4313 crate::user_exercise_states::ReviewingStage::ReviewedAndLocked,
4314 )
4315 .await
4316 .unwrap();
4317
4318 let page = get_page(tx.as_mut(), page_id).await.unwrap();
4319 let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
4320 .await
4321 .unwrap();
4322 assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 1);
4323 }
4324
4325 #[tokio::test]
4326 async fn filter_course_material_pages_with_exercises_hides_lock_chapter_inner_blocks() {
4327 insert_data!(
4328 :tx,
4329 :user,
4330 :org,
4331 :course,
4332 instance: _instance,
4333 :course_module,
4334 chapter: _chapter_id,
4335 page: page_id
4336 );
4337
4338 update_page_content(
4339 tx.as_mut(),
4340 page_id,
4341 &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
4342 )
4343 .await
4344 .unwrap();
4345
4346 let page = get_page(tx.as_mut(), page_id).await.unwrap();
4347 let filtered_pages = filter_course_material_pages_with_exercises(
4348 tx.as_mut(),
4349 None,
4350 vec![PageWithExercises {
4351 page,
4352 exercises: vec![],
4353 }],
4354 )
4355 .await
4356 .unwrap();
4357
4358 assert_eq!(
4359 first_lock_chapter_inner_block_count(&filtered_pages[0].page),
4360 0
4361 );
4362 }
4363
4364 #[tokio::test]
4365 async fn filter_course_material_pages_does_not_initialize_lock_status_when_course_locking_is_disabled()
4366 {
4367 insert_data!(
4368 :tx,
4369 :user,
4370 :org,
4371 :course,
4372 instance: _instance,
4373 :course_module,
4374 chapter: chapter_id,
4375 page: page_id
4376 );
4377
4378 set_course_chapter_locking_enabled(tx.as_mut(), course, false).await;
4379
4380 update_page_content(
4381 tx.as_mut(),
4382 page_id,
4383 &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
4384 )
4385 .await
4386 .unwrap();
4387
4388 let status = user_chapter_locking_statuses::get_or_init_status(
4389 tx.as_mut(),
4390 user,
4391 chapter_id,
4392 Some(course),
4393 Some(false),
4394 )
4395 .await
4396 .unwrap();
4397
4398 let page = get_page(tx.as_mut(), page_id).await.unwrap();
4399 let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
4400 .await
4401 .unwrap();
4402
4403 assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 0);
4404
4405 assert!(status.is_none());
4406 }
4407
4408 #[tokio::test]
4409 async fn page_search_results_do_not_leak_hidden_lock_chapter_snippet_text() {
4410 insert_data!(
4411 :tx,
4412 :user,
4413 :org,
4414 :course,
4415 instance: _instance,
4416 :course_module,
4417 chapter: _chapter_id,
4418 page: page_id
4419 );
4420
4421 update_page_content(
4422 tx.as_mut(),
4423 page_id,
4424 &serde_json::to_value(vec![
4425 GutenbergBlock::paragraph("Visible introduction"),
4426 lock_chapter_test_block("Model solution"),
4427 ])
4428 .unwrap(),
4429 )
4430 .await
4431 .unwrap();
4432
4433 let results = get_page_search_results_for_words(
4434 tx.as_mut(),
4435 course,
4436 &SearchRequest {
4437 query: "Visible".to_string(),
4438 },
4439 )
4440 .await
4441 .unwrap();
4442
4443 let result = results
4444 .iter()
4445 .find(|result| result.id == page_id)
4446 .expect("page should match visible content query");
4447 let content_headline = result.content_headline.as_deref().unwrap_or_default();
4448
4449 assert!(!content_headline.contains("Model solution"));
4450 }
4451
4452 #[tokio::test]
4453 async fn page_upsert_peer_reviews_work() {
4454 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
4455 let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
4456 let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
4457 let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
4458 .await
4459 .unwrap();
4460
4461 let pr1 = CmsPeerOrSelfReviewConfig {
4462 id:pr_id,
4463 exercise_id: Some(exercise_id),
4464 course_id: course,
4465 processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
4466 accepting_threshold: 0.5,
4467 peer_reviews_to_give: 2,
4468 peer_reviews_to_receive: 1,
4469 points_are_all_or_nothing: false,
4470 reset_answer_if_zero_points_from_review: false,
4471 review_instructions: None,
4472 };
4473 let prq = CmsPeerOrSelfReviewQuestion {
4474 id: prq_id,
4475 peer_or_self_review_config_id: pr_id,
4476 answer_required: true,
4477 order_number: 0,
4478 question: "juu".to_string(),
4479 question_type:
4480 crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
4481 weight: 0.31,
4482 };
4483 let mut remapped_exercises = HashMap::new();
4484 remapped_exercises.insert(exercise_id, exercise);
4485 let pr_res = upsert_peer_or_self_review_configs(
4486 tx.as_mut(),
4487 &[],
4488 &[pr1],
4489 &remapped_exercises,
4490 false,
4491 )
4492 .await
4493 .unwrap();
4494 let prq_res =
4495 upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, false)
4496 .await
4497 .unwrap();
4498
4499 assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
4500
4501 assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
4502 assert!(!pr_res.get(&pr_id).unwrap().points_are_all_or_nothing);
4503 assert_eq!(prq_res.get(&prq_id).unwrap().weight, 0.31);
4504 }
4505
4506 #[tokio::test]
4507 async fn page_upsert_peer_reviews_work_retain_ids() {
4508 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
4509 let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
4510 .await
4511 .unwrap();
4512 let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
4513 let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
4514 let pr1 = CmsPeerOrSelfReviewConfig {
4515 id:pr_id,
4516 exercise_id: Some(exercise_id),
4517 course_id: course,
4518 processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
4519 accepting_threshold: 0.5,
4520 peer_reviews_to_give: 2,
4521 peer_reviews_to_receive: 1,
4522 points_are_all_or_nothing: true,
4523 reset_answer_if_zero_points_from_review: false,
4524 review_instructions: None,
4525 };
4526 let prq = CmsPeerOrSelfReviewQuestion {
4527 id: prq_id,
4528 peer_or_self_review_config_id: pr_id,
4529 answer_required: true,
4530 order_number: 0,
4531 question: "juu".to_string(),
4532 question_type:
4533 crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
4534 weight: 0.0,
4535 };
4536 let mut remapped_exercises = HashMap::new();
4537 remapped_exercises.insert(exercise_id, exercise);
4538 let pr_res =
4539 upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[pr1], &remapped_exercises, true)
4540 .await
4541 .unwrap();
4542 let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, true)
4543 .await
4544 .unwrap();
4545
4546 assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
4547 assert!(pr_res.get(&pr_id).unwrap().id == pr_id);
4548
4549 assert!(prq_res.get(&prq_id).unwrap().id == prq_id);
4550 assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
4551 }
4552
4553 #[tokio::test]
4554 async fn page_upsert_peer_reviews_work_empty() {
4555 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
4556 let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
4557 .await
4558 .unwrap();
4559 let mut remapped_exercises = HashMap::new();
4560 remapped_exercises.insert(exercise_id, exercise);
4561 let pr_res =
4562 upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[], &remapped_exercises, true)
4563 .await
4564 .unwrap();
4565 let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[], &pr_res, true)
4566 .await
4567 .unwrap();
4568
4569 assert!(pr_res.is_empty());
4570 assert!(prq_res.is_empty());
4571 }
4572
4573 #[tokio::test]
4574 async fn reorder_top_level_pages_works() {
4575 insert_data!(:tx, :user, :org, :course);
4576
4577 let existing_pages =
4579 get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
4580 .await
4581 .unwrap();
4582 for page in &existing_pages {
4583 delete_page_and_exercises(tx.as_mut(), page.id, user)
4584 .await
4585 .unwrap();
4586 }
4587
4588 let page1 = NewCoursePage::new(course, 0, "top-page-1", "Top Page 1");
4590 let (page1_id, _) = insert_course_page(tx.as_mut(), &page1, user).await.unwrap();
4591 let page2 = NewCoursePage::new(course, 1, "top-page-2", "Top Page 2");
4592 let (page2_id, _) = insert_course_page(tx.as_mut(), &page2, user).await.unwrap();
4593 let page3 = NewCoursePage::new(course, 2, "top-page-3", "Top Page 3");
4594 let (page3_id, _) = insert_course_page(tx.as_mut(), &page3, user).await.unwrap();
4595
4596 let mut pages =
4597 get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
4598 .await
4599 .unwrap();
4600
4601 let page1_index = pages.iter().position(|p| p.id == page1_id).unwrap();
4602 let page2_index = pages.iter().position(|p| p.id == page2_id).unwrap();
4603 let page3_index = pages.iter().position(|p| p.id == page3_id).unwrap();
4604
4605 pages[page1_index].order_number = 2;
4606 pages[page3_index].order_number = 1;
4607 pages[page2_index].order_number = 3;
4608
4609 reorder_pages(tx.as_mut(), &pages, course).await.unwrap();
4611
4612 let page1_updated = get_page(tx.as_mut(), page1_id).await.unwrap();
4614 let page2_updated = get_page(tx.as_mut(), page2_id).await.unwrap();
4615 let page3_updated = get_page(tx.as_mut(), page3_id).await.unwrap();
4616
4617 assert_eq!(page1_updated.order_number, 2);
4618 assert_eq!(page2_updated.order_number, 3);
4619 assert_eq!(page3_updated.order_number, 1);
4620 }
4621}