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