1use std::path::PathBuf;
2
3use crate::{
4 course_modules,
5 pages::{PageMetadata, PageWithExercises},
6 prelude::*,
7};
8use headless_lms_utils::{
9 ApplicationConfiguration, file_store::FileStore,
10 numbers::option_f32_to_f32_two_decimals_with_none_as_zero,
11};
12
13#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
14#[cfg_attr(feature = "ts_rs", derive(TS))]
15pub struct DatabaseChapter {
16 pub id: Uuid,
17 pub created_at: DateTime<Utc>,
18 pub updated_at: DateTime<Utc>,
19 pub name: String,
20 pub color: Option<String>,
21 pub course_id: Uuid,
22 pub deleted_at: Option<DateTime<Utc>>,
23 pub chapter_image_path: Option<String>,
24 pub chapter_number: i32,
25 pub front_page_id: Option<Uuid>,
26 pub opens_at: Option<DateTime<Utc>>,
27 pub deadline: Option<DateTime<Utc>>,
28 pub copied_from: Option<Uuid>,
29 pub course_module_id: Uuid,
30}
31
32impl DatabaseChapter {
33 pub fn has_opened(&self) -> bool {
35 self.opens_at
36 .map(|opens_at| opens_at < Utc::now())
37 .unwrap_or(true)
38 }
39}
40
41#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
42#[cfg_attr(feature = "ts_rs", derive(TS))]
43pub struct Chapter {
44 pub id: Uuid,
45 pub created_at: DateTime<Utc>,
46 pub updated_at: DateTime<Utc>,
47 pub name: String,
48 pub color: Option<String>,
49 pub course_id: Uuid,
50 pub deleted_at: Option<DateTime<Utc>>,
51 pub chapter_image_url: Option<String>,
52 pub chapter_number: i32,
53 pub front_page_id: Option<Uuid>,
54 pub opens_at: Option<DateTime<Utc>>,
55 pub deadline: Option<DateTime<Utc>>,
56 pub copied_from: Option<Uuid>,
57 pub course_module_id: Uuid,
58}
59
60impl Chapter {
61 pub fn from_database_chapter(
62 chapter: &DatabaseChapter,
63 file_store: &dyn FileStore,
64 app_conf: &ApplicationConfiguration,
65 ) -> Self {
66 let chapter_image_url = chapter.chapter_image_path.as_ref().map(|image| {
67 let path = PathBuf::from(image);
68 file_store.get_download_url(path.as_path(), app_conf)
69 });
70 Self {
71 id: chapter.id,
72 created_at: chapter.created_at,
73 updated_at: chapter.updated_at,
74 name: chapter.name.clone(),
75 color: chapter.color.clone(),
76 course_id: chapter.course_id,
77 deleted_at: chapter.deleted_at,
78 chapter_image_url,
79 chapter_number: chapter.chapter_number,
80 front_page_id: chapter.front_page_id,
81 opens_at: chapter.opens_at,
82 copied_from: chapter.copied_from,
83 deadline: chapter.deadline,
84 course_module_id: chapter.course_module_id,
85 }
86 }
87}
88
89#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
90#[cfg_attr(feature = "ts_rs", derive(TS))]
91#[serde(rename_all = "snake_case")]
92pub enum ChapterStatus {
93 Open,
94 Closed,
95}
96
97impl Default for ChapterStatus {
98 fn default() -> Self {
99 Self::Closed
100 }
101}
102
103#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
104pub struct ChapterPagesWithExercises {
105 pub id: Uuid,
106 pub created_at: DateTime<Utc>,
107 pub updated_at: DateTime<Utc>,
108 pub name: String,
109 pub course_id: Uuid,
110 pub deleted_at: Option<DateTime<Utc>>,
111 pub chapter_number: i32,
112 pub pages: Vec<PageWithExercises>,
113}
114
115#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
117#[cfg_attr(feature = "ts_rs", derive(TS))]
118pub struct NewChapter {
119 pub name: String,
120 pub color: Option<String>,
121 pub course_id: Uuid,
122 pub chapter_number: i32,
123 pub front_page_id: Option<Uuid>,
124 pub opens_at: Option<DateTime<Utc>>,
125 pub deadline: Option<DateTime<Utc>>,
126 pub course_module_id: Option<Uuid>,
129}
130
131#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
132#[cfg_attr(feature = "ts_rs", derive(TS))]
133pub struct ChapterUpdate {
134 pub name: String,
135 pub color: Option<String>,
136 pub front_page_id: Option<Uuid>,
137 pub deadline: Option<DateTime<Utc>>,
138 pub opens_at: Option<DateTime<Utc>>,
139 pub course_module_id: Option<Uuid>,
141}
142
143pub struct ChapterInfo {
144 pub chapter_id: Uuid,
145 pub chapter_name: String,
146 pub chapter_front_page_id: Option<Uuid>,
147}
148
149pub async fn insert(
150 conn: &mut PgConnection,
151 pkey_policy: PKeyPolicy<Uuid>,
152 new_chapter: &NewChapter,
153) -> ModelResult<Uuid> {
154 let course_module_id = if let Some(course_module_id) = new_chapter.course_module_id {
160 course_module_id
161 } else {
162 let module = course_modules::get_default_by_course_id(conn, new_chapter.course_id).await?;
163 module.id
164 };
165 let res = sqlx::query!(
167 r"
168INSERT INTO chapters(
169 id,
170 name,
171 color,
172 course_id,
173 chapter_number,
174 deadline,
175 opens_at,
176 course_module_id
177 )
178VALUES($1, $2, $3, $4, $5, $6, $7, $8)
179RETURNING id
180 ",
181 pkey_policy.into_uuid(),
182 new_chapter.name,
183 new_chapter.color,
184 new_chapter.course_id,
185 new_chapter.chapter_number,
186 new_chapter.deadline,
187 new_chapter.opens_at,
188 course_module_id,
189 )
190 .fetch_one(conn)
191 .await?;
192 Ok(res.id)
193}
194
195pub async fn set_front_page(
196 conn: &mut PgConnection,
197 chapter_id: Uuid,
198 front_page_id: Uuid,
199) -> ModelResult<()> {
200 sqlx::query!(
201 "UPDATE chapters SET front_page_id = $1 WHERE id = $2",
202 front_page_id,
203 chapter_id
204 )
205 .execute(conn)
206 .await?;
207 Ok(())
208}
209
210pub async fn set_opens_at(
211 conn: &mut PgConnection,
212 chapter_id: Uuid,
213 opens_at: DateTime<Utc>,
214) -> ModelResult<()> {
215 sqlx::query!(
216 "UPDATE chapters SET opens_at = $1 WHERE id = $2",
217 opens_at,
218 chapter_id,
219 )
220 .execute(conn)
221 .await?;
222 Ok(())
223}
224
225pub async fn is_open(conn: &mut PgConnection, chapter_id: Uuid) -> ModelResult<bool> {
227 let res = sqlx::query!(
228 r#"
229SELECT opens_at
230FROM chapters
231WHERE id = $1
232"#,
233 chapter_id
234 )
235 .fetch_one(conn)
236 .await?;
237 let open = res.opens_at.map(|o| o <= Utc::now()).unwrap_or(true);
238 Ok(open)
239}
240
241pub async fn get_chapter(
242 conn: &mut PgConnection,
243 chapter_id: Uuid,
244) -> ModelResult<DatabaseChapter> {
245 let chapter = sqlx::query_as!(
246 DatabaseChapter,
247 "
248SELECT *
249from chapters
250where id = $1;",
251 chapter_id,
252 )
253 .fetch_one(conn)
254 .await?;
255 Ok(chapter)
256}
257
258pub async fn get_course_id(conn: &mut PgConnection, chapter_id: Uuid) -> ModelResult<Uuid> {
259 let course_id = sqlx::query!("SELECT course_id from chapters where id = $1", chapter_id)
260 .fetch_one(conn)
261 .await?
262 .course_id;
263 Ok(course_id)
264}
265
266pub async fn update_chapter(
267 conn: &mut PgConnection,
268 chapter_id: Uuid,
269 chapter_update: ChapterUpdate,
270) -> ModelResult<DatabaseChapter> {
271 let res = sqlx::query_as!(
272 DatabaseChapter,
273 r#"
274UPDATE chapters
275SET name = $2,
276 deadline = $3,
277 opens_at = $4,
278 course_module_id = $5,
279 color = $6
280WHERE id = $1
281RETURNING *;
282 "#,
283 chapter_id,
284 chapter_update.name,
285 chapter_update.deadline,
286 chapter_update.opens_at,
287 chapter_update.course_module_id,
288 chapter_update.color,
289 )
290 .fetch_one(conn)
291 .await?;
292 Ok(res)
293}
294
295pub async fn update_chapter_image_path(
296 conn: &mut PgConnection,
297 chapter_id: Uuid,
298 chapter_image_path: Option<String>,
299) -> ModelResult<DatabaseChapter> {
300 let updated_chapter = sqlx::query_as!(
301 DatabaseChapter,
302 "
303UPDATE chapters
304SET chapter_image_path = $1
305WHERE id = $2
306RETURNING *;",
307 chapter_image_path,
308 chapter_id
309 )
310 .fetch_one(conn)
311 .await?;
312 Ok(updated_chapter)
313}
314
315#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
316#[cfg_attr(feature = "ts_rs", derive(TS))]
317pub struct ChapterWithStatus {
318 pub id: Uuid,
319 pub created_at: DateTime<Utc>,
320 pub updated_at: DateTime<Utc>,
321 pub name: String,
322 pub color: Option<String>,
323 pub course_id: Uuid,
324 pub deleted_at: Option<DateTime<Utc>>,
325 pub chapter_number: i32,
326 pub front_page_id: Option<Uuid>,
327 pub opens_at: Option<DateTime<Utc>>,
328 pub status: ChapterStatus,
329 pub chapter_image_url: Option<String>,
330 pub course_module_id: Uuid,
331}
332
333impl ChapterWithStatus {
334 pub fn from_database_chapter_timestamp_and_image_url(
335 database_chapter: DatabaseChapter,
336 timestamp: DateTime<Utc>,
337 chapter_image_url: Option<String>,
338 ) -> Self {
339 let open = database_chapter
340 .opens_at
341 .map(|o| o <= timestamp)
342 .unwrap_or(true);
343 let status = if open {
344 ChapterStatus::Open
345 } else {
346 ChapterStatus::Closed
347 };
348 ChapterWithStatus {
349 id: database_chapter.id,
350 created_at: database_chapter.created_at,
351 updated_at: database_chapter.updated_at,
352 name: database_chapter.name,
353 color: database_chapter.color,
354 course_id: database_chapter.course_id,
355 deleted_at: database_chapter.deleted_at,
356 chapter_number: database_chapter.chapter_number,
357 front_page_id: database_chapter.front_page_id,
358 opens_at: database_chapter.opens_at,
359 status,
360 chapter_image_url,
361 course_module_id: database_chapter.course_module_id,
362 }
363 }
364}
365
366#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy)]
367#[cfg_attr(feature = "ts_rs", derive(TS))]
368pub struct UserCourseInstanceChapterProgress {
369 pub score_given: f32,
370 pub score_maximum: i32,
371 pub total_exercises: Option<u32>,
372 pub attempted_exercises: Option<u32>,
373}
374
375pub async fn course_chapters(
376 conn: &mut PgConnection,
377 course_id: Uuid,
378) -> ModelResult<Vec<DatabaseChapter>> {
379 let chapters = sqlx::query_as!(
380 DatabaseChapter,
381 r#"
382SELECT id,
383 created_at,
384 updated_at,
385 name,
386 color,
387 course_id,
388 deleted_at,
389 chapter_image_path,
390 chapter_number,
391 front_page_id,
392 opens_at,
393 copied_from,
394 deadline,
395 course_module_id
396FROM chapters
397WHERE course_id = $1
398 AND deleted_at IS NULL;
399"#,
400 course_id
401 )
402 .fetch_all(conn)
403 .await?;
404 Ok(chapters)
405}
406
407pub async fn course_instance_chapters(
408 conn: &mut PgConnection,
409 course_instance_id: Uuid,
410) -> ModelResult<Vec<DatabaseChapter>> {
411 let chapters = sqlx::query_as!(
412 DatabaseChapter,
413 r#"
414SELECT id,
415 created_at,
416 updated_at,
417 name,
418 color,
419 course_id,
420 deleted_at,
421 chapter_image_path,
422 chapter_number,
423 front_page_id,
424 opens_at,
425 copied_from,
426 deadline,
427 course_module_id
428FROM chapters
429WHERE course_id = (SELECT course_id FROM course_instances WHERE id = $1)
430 AND deleted_at IS NULL;
431"#,
432 course_instance_id
433 )
434 .fetch_all(conn)
435 .await?;
436 Ok(chapters)
437}
438
439pub async fn delete_chapter(
440 conn: &mut PgConnection,
441 chapter_id: Uuid,
442) -> ModelResult<DatabaseChapter> {
443 let mut tx = conn.begin().await?;
444 let deleted = sqlx::query_as!(
445 DatabaseChapter,
446 r#"
447UPDATE chapters
448SET deleted_at = now()
449WHERE id = $1
450RETURNING *;
451"#,
452 chapter_id
453 )
454 .fetch_one(&mut *tx)
455 .await?;
456 sqlx::query!(
458 "UPDATE pages SET deleted_at = now() WHERE chapter_id = $1 AND deleted_at IS NULL;",
459 chapter_id
460 )
461 .execute(&mut *tx)
462 .await?;
463 sqlx::query!(
464 "UPDATE exercise_tasks SET deleted_at = now() WHERE deleted_at IS NULL AND exercise_slide_id IN (SELECT id FROM exercise_slides WHERE exercise_slides.deleted_at IS NULL AND exercise_id IN (SELECT id FROM exercises WHERE chapter_id = $1 AND exercises.deleted_at IS NULL));",
465 chapter_id
466 )
467 .execute(&mut *tx).await?;
468 sqlx::query!(
469 "UPDATE exercise_slides SET deleted_at = now() WHERE deleted_at IS NULL AND exercise_id IN (SELECT id FROM exercises WHERE chapter_id = $1 AND exercises.deleted_at IS NULL);",
470 chapter_id
471 )
472 .execute(&mut *tx).await?;
473 sqlx::query!(
474 "UPDATE exercises SET deleted_at = now() WHERE deleted_at IS NULL AND chapter_id = $1;",
475 chapter_id
476 )
477 .execute(&mut *tx)
478 .await?;
479 tx.commit().await?;
480 Ok(deleted)
481}
482
483pub async fn get_user_course_instance_chapter_progress(
484 conn: &mut PgConnection,
485 course_instance_id: Uuid,
486 chapter_id: Uuid,
487 user_id: Uuid,
488) -> ModelResult<UserCourseInstanceChapterProgress> {
489 let course_instance =
490 crate::course_instances::get_course_instance(conn, course_instance_id).await?;
491 let mut exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
492
493 let exercise_ids: Vec<Uuid> = exercises.iter_mut().map(|e| e.id).collect();
494 let score_maximum: i32 = exercises.into_iter().map(|e| e.score_maximum).sum();
495
496 let user_chapter_metrics = crate::user_exercise_states::get_user_course_chapter_metrics(
497 conn,
498 course_instance.course_id,
499 &exercise_ids,
500 user_id,
501 )
502 .await?;
503
504 let result = UserCourseInstanceChapterProgress {
505 score_given: option_f32_to_f32_two_decimals_with_none_as_zero(
506 user_chapter_metrics.score_given,
507 ),
508 score_maximum,
509 total_exercises: Some(TryInto::try_into(exercise_ids.len())).transpose()?,
510 attempted_exercises: user_chapter_metrics
511 .attempted_exercises
512 .map(TryInto::try_into)
513 .transpose()?,
514 };
515 Ok(result)
516}
517
518pub async fn get_chapter_by_page_id(
519 conn: &mut PgConnection,
520 page_id: Uuid,
521) -> ModelResult<DatabaseChapter> {
522 let chapter = sqlx::query_as!(
523 DatabaseChapter,
524 "
525SELECT c.*
526FROM chapters c,
527 pages p
528WHERE c.id = p.chapter_id
529 AND p.id = $1
530 AND c.deleted_at IS NULL
531 ",
532 page_id
533 )
534 .fetch_one(conn)
535 .await?;
536
537 Ok(chapter)
538}
539
540pub async fn get_chapter_info_by_page_metadata(
541 conn: &mut PgConnection,
542 current_page_metadata: &PageMetadata,
543) -> ModelResult<ChapterInfo> {
544 let chapter_page = sqlx::query_as!(
545 ChapterInfo,
546 "
547 SELECT
548 c.id as chapter_id,
549 c.name as chapter_name,
550 c.front_page_id as chapter_front_page_id
551 FROM chapters c
552 WHERE c.id = $1
553 AND c.course_id = $2
554 AND c.deleted_at IS NULL;
555 ",
556 current_page_metadata.chapter_id,
557 current_page_metadata.course_id
558 )
559 .fetch_one(conn)
560 .await?;
561
562 Ok(chapter_page)
563}
564
565pub async fn set_module(
566 conn: &mut PgConnection,
567 chapter_id: Uuid,
568 module_id: Uuid,
569) -> ModelResult<()> {
570 sqlx::query!(
571 "
572UPDATE chapters
573SET course_module_id = $2
574WHERE id = $1
575",
576 chapter_id,
577 module_id
578 )
579 .execute(conn)
580 .await?;
581 Ok(())
582}
583
584pub async fn get_for_module(conn: &mut PgConnection, module_id: Uuid) -> ModelResult<Vec<Uuid>> {
585 let res = sqlx::query!(
586 "
587SELECT id
588FROM chapters
589WHERE course_module_id = $1
590AND deleted_at IS NULL
591",
592 module_id
593 )
594 .map(|c| c.id)
595 .fetch_all(conn)
596 .await?;
597 Ok(res)
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 mod constraints {
605 use super::*;
606 use crate::{courses::NewCourse, library, test_helper::*};
607
608 #[tokio::test]
609 async fn cannot_create_chapter_for_different_course_than_its_module() {
610 insert_data!(:tx, :user, :org, course: course_1, instance: _instance, :course_module);
611 let course_2 = library::content_management::create_new_course(
612 tx.as_mut(),
613 PKeyPolicy::Generate,
614 NewCourse {
615 name: "".to_string(),
616 slug: "course-2".to_string(),
617 organization_id: org,
618 language_code: "en-US".to_string(),
619 teacher_in_charge_name: "Teacher".to_string(),
620 teacher_in_charge_email: "teacher@example.com".to_string(),
621 description: "".to_string(),
622 is_draft: false,
623 is_test_mode: false,
624 is_unlisted: false,
625 copy_user_permissions: false,
626 is_joinable_by_code_only: false,
627 join_code: None,
628 ask_marketing_consent: false,
629 flagged_answers_threshold: Some(3),
630 can_add_chatbot: false,
631 },
632 user,
633 |_, _, _| unimplemented!(),
634 |_| unimplemented!(),
635 )
636 .await
637 .unwrap()
638 .0
639 .id;
640 let chapter_result_2 = insert(
641 tx.as_mut(),
642 PKeyPolicy::Generate,
643 &NewChapter {
644 name: "Chapter of second course".to_string(),
645 color: None,
646 course_id: course_2,
647 chapter_number: 0,
648 front_page_id: None,
649 opens_at: None,
650 deadline: None,
651 course_module_id: Some(course_module.id),
652 },
653 )
654 .await;
655 assert!(
656 chapter_result_2.is_err(),
657 "Expected chapter creation to fail when course module belongs to a different course."
658 );
659 }
660 }
661}