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