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