Skip to main content

headless_lms_models/library/
content_management.rs

1use futures::future::BoxFuture;
2use headless_lms_utils::document_schema_processor::{
3    COURSE_OBJECTIVE_PLACEHOLDERS, GutenbergBlock,
4};
5use url::Url;
6
7use crate::{
8    SpecFetcher,
9    chapters::{self, DatabaseChapter, NewChapter},
10    course_instances::{CourseInstance, NewCourseInstance},
11    course_language_groups,
12    course_modules::{CourseModule, NewCourseModule},
13    courses::{self, Course, NewCourse},
14    entity_history::{self, HistoryAuthor},
15    exercise_service_info::ExerciseServiceInfoApi,
16    library::entity_snapshots::ContentEntityType,
17    pages::{self, NewPage, Page},
18    peer_or_self_review_questions::CmsPeerOrSelfReviewQuestion,
19    prelude::*,
20};
21
22#[derive(Debug, Clone)]
23pub struct CreateNewCourseFixedIds {
24    pub course_id: Uuid,
25    pub default_course_instance_id: Uuid,
26}
27
28const FRONT_PAGE_TITLE_PLACEHOLDER: &str = "Welcome to...";
29const FRONT_PAGE_SUBTITLE_PLACEHOLDER: &str = "Subheading";
30const FRONT_PAGE_ABOUT_PLACEHOLDER: &str = "This course teaches you xxx.";
31
32/// Text in the default page content that the teacher is meant to replace; a page still holding
33/// one as a whole text attribute hasn't been written yet.
34pub const TEMPLATE_PLACEHOLDER_TEXTS: [&str; 9] = [
35    FRONT_PAGE_TITLE_PLACEHOLDER,
36    FRONT_PAGE_SUBTITLE_PLACEHOLDER,
37    FRONT_PAGE_ABOUT_PLACEHOLDER,
38    COURSE_OBJECTIVE_PLACEHOLDERS[0].0,
39    COURSE_OBJECTIVE_PLACEHOLDERS[0].1,
40    COURSE_OBJECTIVE_PLACEHOLDERS[1].0,
41    COURSE_OBJECTIVE_PLACEHOLDERS[1].1,
42    COURSE_OBJECTIVE_PLACEHOLDERS[2].0,
43    COURSE_OBJECTIVE_PLACEHOLDERS[2].1,
44];
45
46/// The content a new course's front page starts with.
47pub fn default_course_front_page_content() -> Vec<GutenbergBlock> {
48    vec![
49        GutenbergBlock::landing_page_hero_section(
50            FRONT_PAGE_TITLE_PLACEHOLDER,
51            FRONT_PAGE_SUBTITLE_PLACEHOLDER,
52        ),
53        GutenbergBlock::landing_page_copy_text("About this course", FRONT_PAGE_ABOUT_PLACEHOLDER),
54        GutenbergBlock::course_objective_section(),
55        GutenbergBlock::empty_block_from_name("moocfi/course-chapter-grid".to_string()),
56        GutenbergBlock::empty_block_from_name("moocfi/top-level-pages".to_string()),
57        GutenbergBlock::empty_block_from_name("moocfi/congratulations".to_string()),
58        GutenbergBlock::empty_block_from_name("moocfi/course-progress".to_string()),
59    ]
60}
61
62/// Creates a new course with a front page and default instances.
63pub async fn create_new_course(
64    conn: &mut PgConnection,
65    app_config: &ApplicationConfiguration,
66    pkey_policy: PKeyPolicy<CreateNewCourseFixedIds>,
67    new_course: NewCourse,
68    user: Uuid,
69    spec_fetcher: impl SpecFetcher,
70    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
71) -> ModelResult<(Course, Page, CourseInstance, CourseModule)> {
72    let mut tx = conn.begin().await?;
73
74    let course_language_group_id =
75        course_language_groups::insert(&mut tx, PKeyPolicy::Generate, &new_course.slug).await?;
76
77    let course_id = courses::insert(
78        &mut tx,
79        app_config,
80        pkey_policy.map_ref(|x| x.course_id),
81        course_language_group_id,
82        &new_course,
83    )
84    .await?;
85    let course = courses::get_course(&mut tx, course_id).await?;
86
87    let course_front_page = NewPage {
88        chapter_id: None,
89        content: default_course_front_page_content(),
90        course_id: Some(course.id),
91        exam_id: None,
92        front_page_of_chapter_id: None,
93        title: course.name.clone(),
94        url_path: String::from("/"),
95        exercises: vec![],
96        exercise_slides: vec![],
97        exercise_tasks: vec![],
98        content_search_language: None,
99        hidden: false,
100    };
101    let page = crate::pages::insert_page(
102        &mut tx,
103        course_front_page,
104        user,
105        spec_fetcher,
106        fetch_service_info,
107    )
108    .await?;
109
110    // Create default course instance
111    let default_course_instance = crate::course_instances::insert(
112        &mut tx,
113        pkey_policy.map_ref(|x| x.default_course_instance_id),
114        NewCourseInstance {
115            course_id: course.id,
116            name: None,
117            description: None,
118            support_email: None,
119            teacher_in_charge_name: &new_course.teacher_in_charge_name,
120            teacher_in_charge_email: &new_course.teacher_in_charge_email,
121            opening_time: None,
122            closing_time: None,
123        },
124    )
125    .await?;
126
127    // Create default course module
128    let default_module = crate::course_modules::insert(
129        &mut tx,
130        PKeyPolicy::Generate,
131        &NewCourseModule::new_course_default(course.id).set_ects_credits(Some(5.0)),
132    )
133    .await?;
134
135    // Create course default peer review config
136    let peer_or_self_review_config_id =
137        crate::peer_or_self_review_configs::insert(&mut tx, PKeyPolicy::Generate, course.id, None)
138            .await?;
139
140    // Create peer review questions for default peer review config
141    crate::peer_or_self_review_questions::upsert_multiple_peer_or_self_review_questions(
142        &mut tx,
143        &[
144            CmsPeerOrSelfReviewQuestion {
145                id: Uuid::new_v4(),
146                peer_or_self_review_config_id,
147                order_number: 0,
148                question: "General comments".to_string(),
149                question_type:
150                    crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
151                answer_required: false,
152                weight: 0.0,
153            },
154            CmsPeerOrSelfReviewQuestion {
155                id: Uuid::new_v4(),
156                peer_or_self_review_config_id,
157                order_number: 1,
158                question: "The answer was correct".to_string(),
159                question_type:
160                    crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Scale,
161                answer_required: true,
162                weight: 0.0,
163            },
164            CmsPeerOrSelfReviewQuestion {
165                id: Uuid::new_v4(),
166                peer_or_self_review_config_id,
167                order_number: 2,
168                question: "The answer was easy to read".to_string(),
169                question_type:
170                    crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Scale,
171                answer_required: true,
172                weight: 0.0,
173            },
174        ],
175    )
176    .await?;
177
178    tx.commit().await?;
179    Ok((course, page, default_course_instance, default_module))
180}
181
182/// What a new chapter's front page starts as, whether a teacher or an agent creates the chapter.
183pub struct ChapterFrontPage {
184    pub title: String,
185    pub url_path: String,
186    pub content: Vec<GutenbergBlock>,
187}
188
189impl ChapterFrontPage {
190    /// The front page of chapter `chapter_number`, titled `chapter_name`, with the standard blocks.
191    pub fn new(chapter_name: &str, chapter_number: i32) -> Self {
192        Self {
193            title: chapter_name.to_string(),
194            url_path: chapters::front_page_path(chapter_number),
195            content: vec![
196                GutenbergBlock::hero_section(chapter_name, ""),
197                GutenbergBlock::empty_block_from_name("moocfi/pages-in-chapter".to_string()),
198                GutenbergBlock::empty_block_from_name("moocfi/exercises-in-chapter".to_string()),
199            ],
200        }
201    }
202}
203
204/// Creates a new chapter with a front page.
205pub async fn create_new_chapter(
206    conn: &mut PgConnection,
207    pkey_policy: PKeyPolicy<(Uuid, Uuid)>,
208    new_chapter: &NewChapter,
209    user: Uuid,
210    spec_fetcher: impl SpecFetcher,
211    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
212) -> ModelResult<(DatabaseChapter, Page)> {
213    create_new_chapter_with_content(
214        conn,
215        pkey_policy,
216        new_chapter,
217        user,
218        spec_fetcher,
219        fetch_service_info,
220        None,
221    )
222    .await
223}
224
225/// Creates a new chapter with a front page and optional custom content.
226pub async fn create_new_chapter_with_content(
227    conn: &mut PgConnection,
228    pkey_policy: PKeyPolicy<(Uuid, Uuid)>,
229    new_chapter: &NewChapter,
230    user: Uuid,
231    spec_fetcher: impl SpecFetcher,
232    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
233    custom_front_page_content: Option<Vec<GutenbergBlock>>,
234) -> ModelResult<(DatabaseChapter, Page)> {
235    let mut tx = conn.begin().await?;
236    let chapter_id = chapters::insert(&mut tx, pkey_policy.map_ref(|x| x.0), new_chapter).await?;
237    let chapter = chapters::get_chapter(&mut tx, chapter_id).await?;
238
239    let front_page = ChapterFrontPage::new(&chapter.name, chapter.chapter_number);
240    let chapter_frontpage = NewPage {
241        chapter_id: Some(chapter.id),
242        content: custom_front_page_content.unwrap_or(front_page.content),
243        course_id: Some(chapter.course_id),
244        exam_id: None,
245        front_page_of_chapter_id: Some(chapter.id),
246        title: front_page.title,
247        url_path: front_page.url_path,
248        exercises: vec![],
249        exercise_slides: vec![],
250        exercise_tasks: vec![],
251        content_search_language: None,
252        hidden: false,
253    };
254
255    let page = match pkey_policy {
256        PKeyPolicy::Fixed((_, front_page_id)) => {
257            let course = crate::courses::get_course(&mut tx, chapter.course_id).await?;
258            let page = pages::insert_page_row(
259                &mut tx,
260                pages::PageRow {
261                    id: PKeyPolicy::Fixed(front_page_id),
262                    course: Some(&course),
263                    exam_id: None,
264                    chapter_id: Some(chapter.id),
265                    url_path: &chapter_frontpage.url_path,
266                    title: &chapter_frontpage.title,
267                    order_number: 0,
268                    hidden: chapter_frontpage.hidden,
269                    content_search_language: None,
270                    page_language_group_id: None,
271                },
272                &serde_json::to_value(&chapter_frontpage.content)?,
273            )
274            .await?;
275            crate::page_history::insert(
276                &mut tx,
277                PKeyPolicy::Generate,
278                page.id,
279                page.title.as_str(),
280                &crate::page_history::PageHistoryContent {
281                    content: page.content.clone(),
282                    exercises: chapter_frontpage.exercises,
283                    exercise_slides: chapter_frontpage.exercise_slides,
284                    exercise_tasks: chapter_frontpage.exercise_tasks,
285                    peer_or_self_review_configs: vec![],
286                    peer_or_self_review_questions: vec![],
287                },
288                crate::page_history::HistoryChangeReason::PageSaved,
289                HistoryAuthor::user(user),
290                None,
291            )
292            .await?;
293            page
294        }
295        PKeyPolicy::Generate => {
296            pages::insert_page(
297                &mut tx,
298                chapter_frontpage,
299                user,
300                spec_fetcher,
301                fetch_service_info,
302            )
303            .await?
304        }
305    };
306
307    sqlx::query!(
308        "UPDATE chapters SET front_page_id = $1 WHERE id = $2",
309        page.id,
310        chapter.id
311    )
312    .execute(&mut *tx)
313    .await?;
314
315    let updated_chapter = chapters::get_chapter(&mut tx, chapter.id).await?;
316    entity_history::record_current(
317        &mut tx,
318        ContentEntityType::Chapter,
319        chapter.id,
320        HistoryAuthor::user(user),
321    )
322    .await?;
323
324    tx.commit().await?;
325    Ok((updated_chapter, page))
326}