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