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::{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},
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<Course>,
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: 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),
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>(&search_results_sql)
3299 .bind(course_id)
3300 .bind(content_search_language)
3301 .bind(&page_search_request.query)
3302 .bind(&last_word)
3303 .fetch_all(&mut *conn)
3304 .await?;
3305
3306 let sanitized_search_texts = raw_results
3307 .iter()
3308 .map(|result| sanitized_searchable_text_for_public_page(&result.content))
3309 .collect::<Vec<_>>();
3310 let content_headlines = build_public_search_content_headlines(
3311 conn,
3312 content_search_language,
3313 &page_search_request.query,
3314 &last_word,
3315 query_type,
3316 &sanitized_search_texts,
3317 )
3318 .await?;
3319
3320 let search_results = raw_results
3321 .into_iter()
3322 .zip(content_headlines)
3323 .map(|(result, content_headline)| PageSearchResult {
3324 id: result.id,
3325 title_headline: result.title_headline,
3326 rank: result.rank,
3327 content_headline,
3328 url_path: result.url_path,
3329 chapter_name: result.chapter_name,
3330 })
3331 .collect::<Vec<_>>();
3332
3333 Ok(add_course_url_prefix_to_search_results(
3334 search_results,
3335 &course,
3336 ))
3337}
3338
3339async fn build_public_search_content_headlines(
3340 conn: &mut PgConnection,
3341 content_search_language: &str,
3342 query: &str,
3343 last_word: &str,
3344 query_type: PageSearchQueryType,
3345 sanitized_search_texts: &[String],
3346) -> ModelResult<Vec<Option<String>>> {
3347 if sanitized_search_texts.is_empty() {
3348 return Ok(Vec::new());
3349 }
3350
3351 let query_builder = match query_type {
3352 PageSearchQueryType::Phrase => "phraseto_tsquery",
3353 PageSearchQueryType::Words => "plainto_tsquery",
3354 };
3355 let query_builder_comment = match query_type {
3356 PageSearchQueryType::Phrase =>
3357 "-- Converts the search term to a phrase search with phraseto_tsquery but appends ':*' to the last word so that it
3358 -- becomes a prefix match. This way the search will also contain results when the last word in the search term
3359 -- is only partially typed. Note that if to_tsquery($3) decides to stem the word, the replacement will be skipped.",
3360 PageSearchQueryType::Words =>
3361 "-- Converts the search term to a word search with ands between the words with plainto_tsquery but appends ':*' to the
3362 -- last word so that it becomes a prefix match. This way the search will also contain results when the last word in
3363 -- the search term is only partially typed. Note that if to_tsquery($3) decides to stem the word, the replacement
3364 -- will be skipped.",
3365 };
3366
3367 let content_headline_sql = format!(
3368 r#"
3369WITH cte as (
3370 {query_builder_comment}
3371 SELECT ts_rewrite(
3372 {query_builder}($1::regconfig, $2),
3373 to_tsquery($3),
3374 to_tsquery($3 || ':*')
3375 ) as query
3376)
3377SELECT ts_headline(
3378 $1::regconfig,
3379 input.content,
3380 (
3381 SELECT query
3382 from cte
3383 ),
3384 'MaxFragments=1, MaxWords=30, MinWords=10'
3385) as content_headline
3386FROM unnest($4::text[]) WITH ORDINALITY AS input(content, ord)
3387ORDER BY ord;
3388 "#
3389 );
3390
3391 Ok(
3392 sqlx::query_as::<_, SearchContentHeadlineRow>(&content_headline_sql)
3393 .bind(content_search_language)
3394 .bind(query)
3395 .bind(last_word)
3396 .bind(sanitized_search_texts.to_vec())
3397 .fetch_all(&mut *conn)
3398 .await?
3399 .into_iter()
3400 .map(|row| row.content_headline)
3401 .collect(),
3402 )
3403}
3404
3405fn add_course_url_prefix_to_search_results(
3406 search_results: Vec<PageSearchResult>,
3407 course: &Course,
3408) -> Vec<PageSearchResult> {
3409 search_results
3410 .into_iter()
3411 .map(|mut sr| {
3412 let optional_slash = if sr.url_path.starts_with('/') {
3413 ""
3414 } else {
3415 "/"
3416 };
3417 sr.url_path = format!("/{}{}{}", course.slug, optional_slash, sr.url_path);
3418 sr
3419 })
3420 .collect()
3421}
3422
3423pub async fn restore(
3425 conn: &mut PgConnection,
3426 page_id: Uuid,
3427 history_id: Uuid,
3428 author: Uuid,
3429 spec_fetcher: impl SpecFetcher,
3430 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
3431) -> ModelResult<Uuid> {
3432 let page = get_page(conn, page_id).await?;
3434 let history_data = page_history::get_history_data(conn, history_id).await?;
3435
3436 let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(history_data.content.content)?;
3437
3438 update_page(
3439 conn,
3440 PageUpdateArgs {
3441 page_id: page.id,
3442 author,
3443 cms_page_update: CmsPageUpdate {
3444 content: parsed_content,
3445 exercises: history_data.content.exercises,
3446 exercise_slides: history_data.content.exercise_slides,
3447 exercise_tasks: history_data.content.exercise_tasks,
3448 url_path: page.url_path,
3449 title: history_data.title,
3450 chapter_id: page.chapter_id,
3451 },
3452 retain_ids: true,
3453 history_change_reason: HistoryChangeReason::HistoryRestored,
3454 is_exam_page: history_data.exam_id.is_some(),
3455 },
3456 spec_fetcher,
3457 fetch_service_info,
3458 )
3459 .await?;
3460
3461 Ok(history_id)
3462}
3463
3464#[allow(clippy::too_many_arguments)]
3465pub async fn restore_from_history_for_page_id(
3466 conn: &mut PgConnection,
3467 page_id: Uuid,
3468 history_id: Uuid,
3469 authorized_source_page_id: Option<Uuid>,
3470 expected_course_id: Option<Uuid>,
3471 expected_exam_id: Option<Uuid>,
3472 author: Uuid,
3473 spec_fetcher: impl SpecFetcher,
3474 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
3475) -> ModelResult<Uuid> {
3476 let mut tx = conn.begin().await?;
3477 let validated_source_page_id = if let Some(source_page_id) = authorized_source_page_id {
3478 let source_page = sqlx::query!(
3479 r#"
3480SELECT p.id
3481FROM pages p
3482WHERE p.id = $1
3483 AND p.deleted_at IS NULL
3484 AND (p.course_id = $2 OR p.exam_id = $3)
3485FOR UPDATE
3486 "#,
3487 source_page_id,
3488 expected_course_id,
3489 expected_exam_id
3490 )
3491 .fetch_optional(&mut *tx)
3492 .await?;
3493 source_page.map(|row| row.id)
3494 } else {
3495 None
3496 };
3497
3498 sqlx::query!(
3499 r#"
3500SELECT p.id
3501FROM pages p
3502WHERE p.id = $1
3503 AND p.deleted_at IS NULL
3504 AND (p.course_id = $2 OR p.exam_id = $3)
3505FOR UPDATE
3506 "#,
3507 page_id,
3508 expected_course_id,
3509 expected_exam_id
3510 )
3511 .fetch_one(&mut *tx)
3512 .await?;
3513
3514 sqlx::query!(
3515 r#"
3516SELECT ph.id
3517FROM page_history ph
3518WHERE ph.id = $1
3519 AND ph.deleted_at IS NULL
3520 AND (
3521 ph.page_id = $2
3522 OR ph.page_id = $3
3523 )
3524FOR UPDATE
3525 "#,
3526 history_id,
3527 page_id,
3528 validated_source_page_id
3529 )
3530 .fetch_one(&mut *tx)
3531 .await?;
3532
3533 let restored = restore(
3534 &mut tx,
3535 page_id,
3536 history_id,
3537 author,
3538 spec_fetcher,
3539 fetch_service_info,
3540 )
3541 .await?;
3542 tx.commit().await?;
3543 Ok(restored)
3544}
3545
3546pub async fn get_organization_id(conn: &mut PgConnection, page_id: Uuid) -> ModelResult<Uuid> {
3547 let res = sqlx::query!(
3548 "
3549SELECT organizations.id
3550FROM pages
3551 LEFT OUTER JOIN courses ON courses.id = pages.course_id
3552 LEFT OUTER JOIN exams ON exams.id = pages.exam_id
3553 JOIN organizations ON organizations.id = courses.organization_id
3554 OR organizations.id = exams.organization_id
3555WHERE pages.id = $1
3556",
3557 page_id,
3558 )
3559 .fetch_one(&mut *conn)
3560 .await?;
3561 Ok(res.id)
3562}
3563
3564pub async fn get_page_chapter_and_course_information(
3565 conn: &mut PgConnection,
3566 page_id: Uuid,
3567) -> ModelResult<PageChapterAndCourseInformation> {
3568 let res = sqlx::query_as!(
3569 PageChapterAndCourseInformation,
3570 r#"
3571SELECT chapters.name as "chapter_name?",
3572 chapters.chapter_number as "chapter_number?",
3573 courses.name as "course_name?",
3574 courses.slug as "course_slug?",
3575 chapters.front_page_id as "chapter_front_page_id?",
3576 p2.url_path as "chapter_front_page_url_path?",
3577 organizations.slug as organization_slug
3578FROM pages
3579 LEFT JOIN chapters on pages.chapter_id = chapters.id
3580 LEFT JOIN courses on pages.course_id = courses.id
3581 LEFT JOIN pages p2 ON chapters.front_page_id = p2.id
3582 LEFT JOIN organizations on courses.organization_id = organizations.id
3583WHERE pages.id = $1
3584"#,
3585 page_id,
3586 )
3587 .fetch_one(&mut *conn)
3588 .await?;
3589 Ok(res)
3590}
3591
3592pub async fn get_page_by_course_id_and_language_group(
3593 conn: &mut PgConnection,
3594 course_id: Uuid,
3595 page_language_group_id: Uuid,
3596) -> ModelResult<Page> {
3597 let page = sqlx::query_as!(
3598 Page,
3599 "
3600SELECT id,
3601 created_at,
3602 updated_at,
3603 course_id,
3604 exam_id,
3605 chapter_id,
3606 url_path,
3607 title,
3608 deleted_at,
3609 content,
3610 order_number,
3611 copied_from,
3612 hidden,
3613 page_language_group_id
3614FROM pages p
3615WHERE p.course_id = $1
3616 AND p.page_language_group_id = $2
3617 AND p.deleted_at IS NULL
3618 ",
3619 course_id,
3620 page_language_group_id
3621 )
3622 .fetch_one(&mut *conn)
3623 .await?;
3624 Ok(page)
3625}
3626
3627pub async fn reorder_pages(
3631 conn: &mut PgConnection,
3632 pages: &[Page],
3633 course_id: Uuid,
3634) -> ModelResult<()> {
3635 let db_pages =
3636 get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
3637 let chapters = course_chapters(conn, course_id).await?;
3638
3639 let mut chapter_pages: HashMap<Option<Uuid>, Vec<&Page>> = HashMap::new();
3640
3641 for page in pages {
3642 chapter_pages.entry(page.chapter_id).or_default().push(page);
3643 }
3644
3645 let mut normalized_pages = Vec::with_capacity(pages.len());
3646
3647 for (_, chapter_pages) in chapter_pages.iter() {
3648 let mut sorted_pages = chapter_pages.to_vec();
3650 sorted_pages.sort_by(|a, b| {
3651 a.order_number
3652 .cmp(&b.order_number)
3653 .then_with(|| a.id.cmp(&b.id))
3654 });
3655
3656 for (idx, &page) in sorted_pages.iter().enumerate() {
3658 let mut normalized_page = page.clone();
3659 normalized_page.order_number = (idx as i32) + 1; normalized_pages.push(normalized_page);
3661 }
3662 }
3663
3664 let mut tx = conn.begin().await?;
3665
3666 sqlx::query!(
3670 "
3671UPDATE pages
3672SET order_number = floor(random() * (2000000 -200000 + 1) + 200000)
3673WHERE course_id = $1
3674 AND order_number != 0
3675 AND deleted_at IS NULL
3676 ",
3677 course_id
3678 )
3679 .execute(&mut *tx)
3680 .await?;
3681
3682 for page in normalized_pages {
3684 if let Some(matching_db_page) = db_pages.iter().find(|p| p.id == page.id) {
3685 if matching_db_page.chapter_id == page.chapter_id {
3686 sqlx::query!(
3688 "UPDATE pages SET order_number = $2 WHERE id = $1",
3689 page.id,
3690 page.order_number
3691 )
3692 .execute(&mut *tx)
3693 .await?;
3694 } else {
3695 if let Some(old_chapter_id) = matching_db_page.chapter_id {
3697 if let Some(new_chapter_id) = page.chapter_id {
3698 if let Some(old_chapter) = chapters.iter().find(|o| o.id == old_chapter_id)
3700 {
3701 if let Some(new_chapter) =
3702 chapters.iter().find(|o| o.id == new_chapter_id)
3703 {
3704 let old_path = &page.url_path;
3705 let new_path = old_path.replacen(
3706 &old_chapter.chapter_number.to_string(),
3707 &new_chapter.chapter_number.to_string(),
3708 1,
3709 );
3710 sqlx::query!(
3711 "UPDATE pages SET url_path = $2, chapter_id = $3, order_number = $4 WHERE pages.id = $1",
3712 page.id,
3713 new_path,
3714 new_chapter.id,
3715 page.order_number
3716 )
3717 .execute(&mut *tx)
3718 .await?;
3719 sqlx::query!(
3720 "INSERT INTO url_redirections(destination_page_id, old_url_path, course_id) VALUES ($1, $2, $3)",
3721 page.id,
3722 old_path,
3723 course_id
3724 )
3725 .execute(&mut *tx)
3726 .await?;
3727 } else {
3728 return Err(model_err!(
3729 InvalidRequest,
3730 "New chapter not found".to_string()
3731 ));
3732 }
3733 } else {
3734 return Err(model_err!(
3735 InvalidRequest,
3736 "Old chapter not found".to_string()
3737 ));
3738 }
3739 } else {
3740 return Err(model_err!(
3742 InvalidRequest,
3743 "Making a chapter page a top level page is not supported yet"
3744 .to_string()
3745 ));
3746 }
3747 } else {
3748 error!(
3749 "Cannot move a top level page to a chapter. matching_db_page.chapter_id: {:?} page.chapter_id: {:?}",
3750 matching_db_page.chapter_id, page.chapter_id
3751 );
3752 return Err(model_err!(
3754 InvalidRequest,
3755 "Moving a top level page to a chapter is not supported yet".to_string()
3756 ));
3757 }
3758 }
3759 } else {
3760 return Err(model_err!(
3761 InvalidRequest,
3762 format!("Page {} does exist in course {}", page.id, course_id)
3763 ));
3764 }
3765 }
3766 tx.commit().await?;
3767 Ok(())
3768}
3769
3770pub async fn reorder_chapters(
3771 conn: &mut PgConnection,
3772 chapters: &[Chapter],
3773 course_id: Uuid,
3774) -> ModelResult<()> {
3775 let db_chapters = course_chapters(conn, course_id).await?;
3776 let mut tx = conn.begin().await?;
3777 for chapter in chapters {
3781 if let Some(matching_db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id)
3782 && let Some(old_chapter) = db_chapters.iter().find(|o| o.id == matching_db_chapter.id)
3783 {
3784 sqlx::query!(
3787 "UPDATE chapters
3788 SET chapter_number = floor(random() * (20000000 - 2000000 + 1) + 200000)
3789 WHERE chapters.id = $1
3790 AND chapters.course_id = $2
3791 AND deleted_at IS NULL",
3792 matching_db_chapter.id,
3793 course_id
3794 )
3795 .execute(&mut *tx)
3796 .await?;
3797
3798 let chapter_with_randomized_chapter_number =
3800 get_chapter(&mut tx, matching_db_chapter.id).await?;
3801 let random_chapter_number = chapter_with_randomized_chapter_number.chapter_number;
3802 let pages =
3803 get_chapter_pages(&mut tx, chapter_with_randomized_chapter_number.id).await?;
3804
3805 for page in pages {
3806 let old_path = &page.url_path;
3807 let new_path = old_path.replacen(
3808 &old_chapter.chapter_number.to_string(),
3809 &random_chapter_number.to_string(),
3810 1,
3811 );
3812
3813 sqlx::query!(
3815 "UPDATE pages SET url_path = $2 WHERE pages.id = $1",
3816 page.id,
3817 new_path
3818 )
3819 .execute(&mut *tx)
3820 .await?;
3821 }
3822 }
3823 }
3824
3825 for chapter in chapters {
3826 if let Some(matching_db_chapter) = db_chapters.iter().find(|c| c.id == chapter.id) {
3827 if let Some(new_chapter) = chapters.iter().find(|o| o.id == matching_db_chapter.id) {
3828 let new_chapter_number = &new_chapter.chapter_number;
3829
3830 let randomized_chapter = get_chapter(&mut tx, chapter.id).await?;
3831
3832 let randomized_chapter_number = randomized_chapter.chapter_number;
3833
3834 sqlx::query!(
3836 "UPDATE chapters SET chapter_number = $2 WHERE chapters.id = $1",
3837 chapter.id,
3838 new_chapter_number
3839 )
3840 .execute(&mut *tx)
3841 .await?;
3842
3843 let pages = get_chapter_pages(&mut tx, chapter.id).await?;
3845
3846 for page in pages {
3847 let path_with_temp_random_number = &page.url_path;
3848 let new_path = path_with_temp_random_number.replacen(
3849 &randomized_chapter_number.to_string(),
3850 &new_chapter_number.to_string(),
3851 1,
3852 );
3853 let old_path = path_with_temp_random_number.replacen(
3854 &randomized_chapter_number.to_string(),
3855 &chapter.chapter_number.to_string(),
3856 1,
3857 );
3858 sqlx::query!(
3860 "UPDATE pages SET url_path = $2 WHERE pages.id = $1",
3861 page.id,
3862 new_path
3863 )
3864 .execute(&mut *tx)
3865 .await?;
3866
3867 crate::url_redirections::upsert(
3868 &mut tx,
3869 PKeyPolicy::Generate,
3870 page.id,
3871 &old_path,
3872 course_id,
3873 )
3874 .await?;
3875 }
3876 } else {
3877 return Err(model_err!(
3878 InvalidRequest,
3879 "New chapter not found".to_string()
3880 ));
3881 }
3882 } else {
3883 return Err(model_err!(
3884 InvalidRequest,
3885 "Matching DB chapters not found".to_string()
3886 ));
3887 }
3888 }
3889
3890 tx.commit().await?;
3891 Ok(())
3892}
3893
3894pub async fn is_chapter_front_page(
3895 conn: &mut PgConnection,
3896 page_id: Uuid,
3897) -> ModelResult<IsChapterFrontPage> {
3898 let chapter = get_chapter_by_page_id(conn, page_id).await?;
3899
3900 Ok(chapter.front_page_id.map_or(
3901 IsChapterFrontPage {
3902 is_chapter_front_page: false,
3903 },
3904 |id| IsChapterFrontPage {
3905 is_chapter_front_page: id == page_id,
3906 },
3907 ))
3908}
3909
3910pub async fn update_page_details(
3911 conn: &mut PgConnection,
3912 page_id: Uuid,
3913 page_details_update: &PageDetailsUpdate,
3914) -> ModelResult<()> {
3915 let normalized_url_path = normalize_url_path_for_storage(&page_details_update.url_path);
3916 let mut tx = conn.begin().await?;
3917 let page_before_update = get_page(&mut tx, page_id).await?;
3918 sqlx::query!(
3919 "
3920UPDATE pages
3921SET title = $2,
3922 url_path = $3
3923WHERE id = $1
3924",
3925 page_id,
3926 page_details_update.title,
3927 normalized_url_path,
3928 )
3929 .execute(&mut *tx)
3930 .await?;
3931
3932 if let Some(course_id) = page_before_update.course_id
3933 && page_before_update.url_path != normalized_url_path
3934 {
3935 crate::url_redirections::upsert(
3937 &mut tx,
3938 PKeyPolicy::Generate,
3939 page_id,
3940 &page_before_update.url_path,
3941 course_id,
3942 )
3943 .await?;
3944 }
3945
3946 tx.commit().await?;
3947 Ok(())
3948}
3949
3950pub async fn get_by_ids_and_visibility(
3951 conn: &mut PgConnection,
3952 ids: &[Uuid],
3953 page_visibility: PageVisibility,
3954) -> ModelResult<Vec<Page>> {
3955 let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
3956 let pages = sqlx::query_as!(
3957 Page,
3958 "
3959SELECT id,
3960 created_at,
3961 updated_at,
3962 course_id,
3963 exam_id,
3964 chapter_id,
3965 url_path,
3966 title,
3967 deleted_at,
3968 content,
3969 order_number,
3970 copied_from,
3971 hidden,
3972 page_language_group_id
3973FROM pages
3974WHERE id = ANY($1)
3975 AND hidden IS DISTINCT FROM $2
3976 AND deleted_at IS NULL
3977 ",
3978 ids,
3979 inverse_visibility_filter
3980 )
3981 .fetch_all(conn)
3982 .await?;
3983 Ok(pages)
3984}
3985
3986pub async fn get_by_ids_deleted_and_visibility(
3987 conn: &mut PgConnection,
3988 ids: &[Uuid],
3989 page_visibility: PageVisibility,
3990) -> ModelResult<Vec<Page>> {
3991 let inverse_visibility_filter = page_visibility.get_inverse_visibility_filter();
3992 let pages = sqlx::query_as!(
3993 Page,
3994 "
3995SELECT id,
3996 created_at,
3997 updated_at,
3998 course_id,
3999 exam_id,
4000 chapter_id,
4001 url_path,
4002 title,
4003 deleted_at,
4004 content,
4005 order_number,
4006 copied_from,
4007 hidden,
4008 page_language_group_id
4009FROM pages
4010WHERE id = ANY($1)
4011 AND hidden IS DISTINCT FROM $2
4012 AND deleted_at IS NOT NULL
4013 ",
4014 ids,
4015 inverse_visibility_filter
4016 )
4017 .fetch_all(conn)
4018 .await?;
4019 Ok(pages)
4020}
4021
4022#[cfg(test)]
4023mod test {
4024 use chrono::TimeZone;
4025
4026 use super::*;
4027 use crate::{exams::NewExam, test_helper::*};
4028
4029 #[test]
4030 fn normalizes_decoded_page_paths_for_storage_and_lookup() {
4031 assert_eq!(
4032 normalize_url_path_for_storage("/foo bar#part"),
4033 "/foo%20bar%23part"
4034 );
4035 assert_eq!(
4036 normalize_url_path_for_storage("/foo%20bar%23part"),
4037 "/foo%20bar%23part"
4038 );
4039 assert_eq!(
4040 normalize_url_path_for_storage(" /literal%percent "),
4041 "/literal%25percent"
4042 );
4043 assert_eq!(
4044 normalize_url_path_for_storage(" /literal%25percent "),
4045 "/literal%25percent"
4046 );
4047 }
4048
4049 #[tokio::test]
4050 async fn gets_organization_id() {
4051 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, :page);
4052
4053 let course_page_org = get_organization_id(tx.as_mut(), page).await.unwrap();
4054 assert_eq!(org, course_page_org);
4055
4056 let new_exam_id = crate::exams::insert(
4057 tx.as_mut(),
4058 PKeyPolicy::Generate,
4059 &NewExam {
4060 name: "name".to_string(),
4061 starts_at: None,
4062 ends_at: None,
4063 time_minutes: 120,
4064 organization_id: org,
4065 minimum_points_treshold: 24,
4066 grade_manually: false,
4067 },
4068 )
4069 .await
4070 .unwrap();
4071 let page = crate::pages::insert_page(
4072 tx.as_mut(),
4073 NewPage {
4074 exercises: vec![],
4075 exercise_slides: vec![],
4076 exercise_tasks: vec![],
4077 content: vec![],
4078 url_path: "url".to_string(),
4079 title: "title".to_string(),
4080 course_id: None,
4081 exam_id: Some(new_exam_id),
4082 chapter_id: None,
4083 front_page_of_chapter_id: None,
4084 content_search_language: None,
4085 },
4086 user,
4087 |_, _, _| unimplemented!(),
4088 |_| unimplemented!(),
4089 )
4090 .await
4091 .unwrap();
4092 let exam_page_org = get_organization_id(tx.as_mut(), page.id).await.unwrap();
4093 assert_eq!(org, exam_page_org);
4094 }
4095
4096 #[tokio::test]
4097 async fn page_update_validation_works() {
4098 let e1 = CmsPageExercise {
4099 id: Uuid::parse_str("0c9dca80-5904-4d35-a945-8c080446f667").unwrap(),
4100 name: "".to_string(),
4101 order_number: 1,
4102 score_maximum: 1,
4103 max_tries_per_slide: None,
4104 limit_number_of_tries: false,
4105 deadline: Some(Utc.with_ymd_and_hms(2125, 1, 1, 23, 59, 59).unwrap()),
4106 needs_peer_review: false,
4107 needs_self_review: false,
4108 peer_or_self_review_config: None,
4109 peer_or_self_review_questions: None,
4110 use_course_default_peer_or_self_review_config: false,
4111 teacher_reviews_answer_after_locking: true,
4112 };
4113 let e1_s1 = CmsPageExerciseSlide {
4114 id: Uuid::parse_str("43380e81-6ff2-4f46-9f38-af0ac6a8421a").unwrap(),
4115 exercise_id: e1.id,
4116 order_number: 1,
4117 };
4118 let e1_s1_t1 = CmsPageExerciseTask {
4119 id: Uuid::parse_str("6fb19c22-bca0-42cf-8be5-4141e21cc7a9").unwrap(),
4120 exercise_slide_id: e1_s1.id,
4121 assignment: serde_json::json!([]),
4122 exercise_type: "exercise".to_string(),
4123 private_spec: None,
4124 order_number: 1,
4125 };
4126
4127 assert!(
4129 create_update(vec![], vec![], vec![])
4130 .validate_exercise_data()
4131 .is_ok()
4132 );
4133
4134 assert!(
4136 create_update(
4137 vec![e1.clone()],
4138 vec![e1_s1.clone()],
4139 vec![e1_s1_t1.clone()],
4140 )
4141 .validate_exercise_data()
4142 .is_ok()
4143 );
4144
4145 assert!(
4147 create_update(vec![e1.clone()], vec![], vec![e1_s1_t1],)
4148 .validate_exercise_data()
4149 .is_err()
4150 );
4151
4152 assert!(
4154 create_update(vec![e1], vec![e1_s1], vec![],)
4155 .validate_exercise_data()
4156 .is_err()
4157 );
4158 }
4159
4160 fn create_update(
4161 exercises: Vec<CmsPageExercise>,
4162 exercise_slides: Vec<CmsPageExerciseSlide>,
4163 exercise_tasks: Vec<CmsPageExerciseTask>,
4164 ) -> CmsPageUpdate {
4165 CmsPageUpdate {
4166 content: vec![],
4167 exercises,
4168 exercise_slides,
4169 exercise_tasks,
4170 url_path: "".to_string(),
4171 title: "".to_string(),
4172 chapter_id: None,
4173 }
4174 }
4175
4176 fn lock_chapter_test_block(hidden_text: &str) -> GutenbergBlock {
4177 GutenbergBlock::block_with_name_attributes_and_inner_blocks(
4178 "moocfi/lock-chapter",
4179 headless_lms_utils::attributes! {},
4180 vec![GutenbergBlock::paragraph(hidden_text)],
4181 )
4182 }
4183
4184 fn first_lock_chapter_inner_block_count(page: &Page) -> usize {
4185 page.blocks_cloned().unwrap()[0].inner_blocks.len()
4186 }
4187
4188 async fn set_course_chapter_locking_enabled(
4190 tx: &mut PgConnection,
4191 course_id: Uuid,
4192 chapter_locking_enabled: bool,
4193 ) {
4194 let course_before_update = courses::get_course(tx, course_id).await.unwrap();
4195 courses::update_course(
4196 tx,
4197 course_id,
4198 courses::CourseUpdate {
4199 chapter_locking_enabled,
4200 name: course_before_update.name,
4201 description: course_before_update.description,
4202 is_draft: course_before_update.is_draft,
4203 is_test_mode: course_before_update.is_test_mode,
4204 can_add_chatbot: course_before_update.can_add_chatbot,
4205 is_unlisted: course_before_update.is_unlisted,
4206 is_joinable_by_code_only: course_before_update.is_joinable_by_code_only,
4207 ask_marketing_consent: course_before_update.ask_marketing_consent,
4208 flagged_answers_threshold: course_before_update
4209 .flagged_answers_threshold
4210 .unwrap_or_default(),
4211 flagged_answers_skip_manual_review_and_allow_retry: course_before_update
4212 .flagged_answers_skip_manual_review_and_allow_retry,
4213 closed_at: course_before_update.closed_at,
4214 closed_additional_message: course_before_update.closed_additional_message,
4215 closed_course_successor_id: course_before_update.closed_course_successor_id,
4216 },
4217 )
4218 .await
4219 .unwrap();
4220 }
4221
4222 #[test]
4223 fn sanitized_searchable_text_for_public_page_hides_lock_chapter_inner_blocks() {
4224 let content = serde_json::to_value(vec![
4225 GutenbergBlock::paragraph("Visible introduction"),
4226 lock_chapter_test_block("Model solution"),
4227 ])
4228 .unwrap();
4229
4230 let searchable_text = sanitized_searchable_text_for_public_page(&content);
4231
4232 assert!(searchable_text.contains("Visible introduction"));
4233 assert!(!searchable_text.contains("Model solution"));
4234 }
4235
4236 #[tokio::test]
4237 async fn filter_course_material_pages_hides_lock_chapter_inner_blocks_until_reviews_complete() {
4238 insert_data!(
4239 :tx,
4240 :user,
4241 :org,
4242 :course,
4243 instance: _instance,
4244 :course_module,
4245 chapter: chapter_id,
4246 page: page_id,
4247 exercise: exercise_id,
4248 slide: _exercise_slide_id,
4249 task: _exercise_task_id
4250 );
4251 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
4252
4253 update_page_content(
4254 tx.as_mut(),
4255 page_id,
4256 &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
4257 )
4258 .await
4259 .unwrap();
4260
4261 crate::exercises::update_teacher_reviews_answer_after_locking(
4262 tx.as_mut(),
4263 exercise_id,
4264 true,
4265 )
4266 .await
4267 .unwrap();
4268 crate::user_exercise_states::get_or_create_user_exercise_state(
4269 tx.as_mut(),
4270 user,
4271 exercise_id,
4272 Some(course),
4273 None,
4274 )
4275 .await
4276 .unwrap();
4277 crate::user_chapter_locking_statuses::complete_and_lock_chapter(
4278 tx.as_mut(),
4279 user,
4280 chapter_id,
4281 course,
4282 )
4283 .await
4284 .unwrap();
4285 crate::user_exercise_states::update_reviewing_stage(
4286 tx.as_mut(),
4287 user,
4288 CourseOrExamId::Course(course),
4289 exercise_id,
4290 crate::user_exercise_states::ReviewingStage::WaitingForManualGrading,
4291 )
4292 .await
4293 .unwrap();
4294
4295 let page = get_page(tx.as_mut(), page_id).await.unwrap();
4296 let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
4297 .await
4298 .unwrap();
4299 assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 0);
4300
4301 crate::user_exercise_states::update_reviewing_stage(
4302 tx.as_mut(),
4303 user,
4304 CourseOrExamId::Course(course),
4305 exercise_id,
4306 crate::user_exercise_states::ReviewingStage::ReviewedAndLocked,
4307 )
4308 .await
4309 .unwrap();
4310
4311 let page = get_page(tx.as_mut(), page_id).await.unwrap();
4312 let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
4313 .await
4314 .unwrap();
4315 assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 1);
4316 }
4317
4318 #[tokio::test]
4319 async fn filter_course_material_pages_with_exercises_hides_lock_chapter_inner_blocks() {
4320 insert_data!(
4321 :tx,
4322 :user,
4323 :org,
4324 :course,
4325 instance: _instance,
4326 :course_module,
4327 chapter: _chapter_id,
4328 page: page_id
4329 );
4330
4331 update_page_content(
4332 tx.as_mut(),
4333 page_id,
4334 &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
4335 )
4336 .await
4337 .unwrap();
4338
4339 let page = get_page(tx.as_mut(), page_id).await.unwrap();
4340 let filtered_pages = filter_course_material_pages_with_exercises(
4341 tx.as_mut(),
4342 None,
4343 vec![PageWithExercises {
4344 page,
4345 exercises: vec![],
4346 }],
4347 )
4348 .await
4349 .unwrap();
4350
4351 assert_eq!(
4352 first_lock_chapter_inner_block_count(&filtered_pages[0].page),
4353 0
4354 );
4355 }
4356
4357 #[tokio::test]
4358 async fn filter_course_material_pages_does_not_initialize_lock_status_when_course_locking_is_disabled()
4359 {
4360 insert_data!(
4361 :tx,
4362 :user,
4363 :org,
4364 :course,
4365 instance: _instance,
4366 :course_module,
4367 chapter: chapter_id,
4368 page: page_id
4369 );
4370
4371 set_course_chapter_locking_enabled(tx.as_mut(), course, false).await;
4372
4373 update_page_content(
4374 tx.as_mut(),
4375 page_id,
4376 &serde_json::to_value(vec![lock_chapter_test_block("Model solution")]).unwrap(),
4377 )
4378 .await
4379 .unwrap();
4380
4381 let status = user_chapter_locking_statuses::get_or_init_status(
4382 tx.as_mut(),
4383 user,
4384 chapter_id,
4385 Some(course),
4386 Some(false),
4387 )
4388 .await
4389 .unwrap();
4390
4391 let page = get_page(tx.as_mut(), page_id).await.unwrap();
4392 let filtered_pages = filter_course_material_pages(tx.as_mut(), Some(user), vec![page])
4393 .await
4394 .unwrap();
4395
4396 assert_eq!(first_lock_chapter_inner_block_count(&filtered_pages[0]), 0);
4397
4398 assert!(status.is_none());
4399 }
4400
4401 #[tokio::test]
4402 async fn page_search_results_do_not_leak_hidden_lock_chapter_snippet_text() {
4403 insert_data!(
4404 :tx,
4405 :user,
4406 :org,
4407 :course,
4408 instance: _instance,
4409 :course_module,
4410 chapter: _chapter_id,
4411 page: page_id
4412 );
4413
4414 update_page_content(
4415 tx.as_mut(),
4416 page_id,
4417 &serde_json::to_value(vec![
4418 GutenbergBlock::paragraph("Visible introduction"),
4419 lock_chapter_test_block("Model solution"),
4420 ])
4421 .unwrap(),
4422 )
4423 .await
4424 .unwrap();
4425
4426 let results = get_page_search_results_for_words(
4427 tx.as_mut(),
4428 course,
4429 &SearchRequest {
4430 query: "Visible".to_string(),
4431 },
4432 )
4433 .await
4434 .unwrap();
4435
4436 let result = results
4437 .iter()
4438 .find(|result| result.id == page_id)
4439 .expect("page should match visible content query");
4440 let content_headline = result.content_headline.as_deref().unwrap_or_default();
4441
4442 assert!(!content_headline.contains("Model solution"));
4443 }
4444
4445 #[tokio::test]
4446 async fn page_upsert_peer_reviews_work() {
4447 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
4448 let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
4449 let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
4450 let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
4451 .await
4452 .unwrap();
4453
4454 let pr1 = CmsPeerOrSelfReviewConfig {
4455 id:pr_id,
4456 exercise_id: Some(exercise_id),
4457 course_id: course,
4458 processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
4459 accepting_threshold: 0.5,
4460 peer_reviews_to_give: 2,
4461 peer_reviews_to_receive: 1,
4462 points_are_all_or_nothing: false,
4463 reset_answer_if_zero_points_from_review: false,
4464 review_instructions: None,
4465 };
4466 let prq = CmsPeerOrSelfReviewQuestion {
4467 id: prq_id,
4468 peer_or_self_review_config_id: pr_id,
4469 answer_required: true,
4470 order_number: 0,
4471 question: "juu".to_string(),
4472 question_type:
4473 crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
4474 weight: 0.31,
4475 };
4476 let mut remapped_exercises = HashMap::new();
4477 remapped_exercises.insert(exercise_id, exercise);
4478 let pr_res = upsert_peer_or_self_review_configs(
4479 tx.as_mut(),
4480 &[],
4481 &[pr1],
4482 &remapped_exercises,
4483 false,
4484 )
4485 .await
4486 .unwrap();
4487 let prq_res =
4488 upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, false)
4489 .await
4490 .unwrap();
4491
4492 assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
4493
4494 assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
4495 assert!(!pr_res.get(&pr_id).unwrap().points_are_all_or_nothing);
4496 assert_eq!(prq_res.get(&prq_id).unwrap().weight, 0.31);
4497 }
4498
4499 #[tokio::test]
4500 async fn page_upsert_peer_reviews_work_retain_ids() {
4501 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
4502 let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
4503 .await
4504 .unwrap();
4505 let pr_id = Uuid::parse_str("9b69dc5e-0eca-4fcd-8fd2-031a3a65da82").unwrap();
4506 let prq_id = Uuid::parse_str("de18fa14-4ac6-4b57-b9f8-4843fa52d948").unwrap();
4507 let pr1 = CmsPeerOrSelfReviewConfig {
4508 id:pr_id,
4509 exercise_id: Some(exercise_id),
4510 course_id: course,
4511 processing_strategy: crate::peer_or_self_review_configs::PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage,
4512 accepting_threshold: 0.5,
4513 peer_reviews_to_give: 2,
4514 peer_reviews_to_receive: 1,
4515 points_are_all_or_nothing: true,
4516 reset_answer_if_zero_points_from_review: false,
4517 review_instructions: None,
4518 };
4519 let prq = CmsPeerOrSelfReviewQuestion {
4520 id: prq_id,
4521 peer_or_self_review_config_id: pr_id,
4522 answer_required: true,
4523 order_number: 0,
4524 question: "juu".to_string(),
4525 question_type:
4526 crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
4527 weight: 0.0,
4528 };
4529 let mut remapped_exercises = HashMap::new();
4530 remapped_exercises.insert(exercise_id, exercise);
4531 let pr_res =
4532 upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[pr1], &remapped_exercises, true)
4533 .await
4534 .unwrap();
4535 let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[prq], &pr_res, true)
4536 .await
4537 .unwrap();
4538
4539 assert!(pr_res.get(&pr_id).unwrap().accepting_threshold == 0.5);
4540 assert!(pr_res.get(&pr_id).unwrap().id == pr_id);
4541
4542 assert!(prq_res.get(&prq_id).unwrap().id == prq_id);
4543 assert!(prq_res.get(&prq_id).unwrap().question == *"juu");
4544 }
4545
4546 #[tokio::test]
4547 async fn page_upsert_peer_reviews_work_empty() {
4548 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page, exercise: exercise_id);
4549 let exercise = crate::exercises::get_by_id(tx.as_mut(), exercise_id)
4550 .await
4551 .unwrap();
4552 let mut remapped_exercises = HashMap::new();
4553 remapped_exercises.insert(exercise_id, exercise);
4554 let pr_res =
4555 upsert_peer_or_self_review_configs(tx.as_mut(), &[], &[], &remapped_exercises, true)
4556 .await
4557 .unwrap();
4558 let prq_res = upsert_peer_or_self_review_questions(tx.as_mut(), &[], &[], &pr_res, true)
4559 .await
4560 .unwrap();
4561
4562 assert!(pr_res.is_empty());
4563 assert!(prq_res.is_empty());
4564 }
4565
4566 #[tokio::test]
4567 async fn reorder_top_level_pages_works() {
4568 insert_data!(:tx, :user, :org, :course);
4569
4570 let existing_pages =
4572 get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
4573 .await
4574 .unwrap();
4575 for page in &existing_pages {
4576 delete_page_and_exercises(tx.as_mut(), page.id, user)
4577 .await
4578 .unwrap();
4579 }
4580
4581 let page1 = NewCoursePage::new(course, 0, "top-page-1", "Top Page 1");
4583 let (page1_id, _) = insert_course_page(tx.as_mut(), &page1, user).await.unwrap();
4584 let page2 = NewCoursePage::new(course, 1, "top-page-2", "Top Page 2");
4585 let (page2_id, _) = insert_course_page(tx.as_mut(), &page2, user).await.unwrap();
4586 let page3 = NewCoursePage::new(course, 2, "top-page-3", "Top Page 3");
4587 let (page3_id, _) = insert_course_page(tx.as_mut(), &page3, user).await.unwrap();
4588
4589 let mut pages =
4590 get_all_by_course_id_and_visibility(tx.as_mut(), course, PageVisibility::Any)
4591 .await
4592 .unwrap();
4593
4594 let page1_index = pages.iter().position(|p| p.id == page1_id).unwrap();
4595 let page2_index = pages.iter().position(|p| p.id == page2_id).unwrap();
4596 let page3_index = pages.iter().position(|p| p.id == page3_id).unwrap();
4597
4598 pages[page1_index].order_number = 2;
4599 pages[page3_index].order_number = 1;
4600 pages[page2_index].order_number = 3;
4601
4602 reorder_pages(tx.as_mut(), &pages, course).await.unwrap();
4604
4605 let page1_updated = get_page(tx.as_mut(), page1_id).await.unwrap();
4607 let page2_updated = get_page(tx.as_mut(), page2_id).await.unwrap();
4608 let page3_updated = get_page(tx.as_mut(), page3_id).await.unwrap();
4609
4610 assert_eq!(page1_updated.order_number, 2);
4611 assert_eq!(page2_updated.order_number, 3);
4612 assert_eq!(page3_updated.order_number, 1);
4613 }
4614}