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
450AND deleted_at IS NULL
451RETURNING *;
452"#,
453 chapter_id
454 )
455 .fetch_one(&mut *tx)
456 .await?;
457 sqlx::query!(
459 "UPDATE pages SET deleted_at = now() WHERE chapter_id = $1 AND deleted_at IS NULL;",
460 chapter_id
461 )
462 .execute(&mut *tx)
463 .await?;
464 sqlx::query!(
465 "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));",
466 chapter_id
467 )
468 .execute(&mut *tx).await?;
469 sqlx::query!(
470 "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);",
471 chapter_id
472 )
473 .execute(&mut *tx).await?;
474 sqlx::query!(
475 "UPDATE exercises SET deleted_at = now() WHERE deleted_at IS NULL AND chapter_id = $1;",
476 chapter_id
477 )
478 .execute(&mut *tx)
479 .await?;
480 tx.commit().await?;
481 Ok(deleted)
482}
483
484pub async fn get_user_course_instance_chapter_progress(
485 conn: &mut PgConnection,
486 course_instance_id: Uuid,
487 chapter_id: Uuid,
488 user_id: Uuid,
489) -> ModelResult<UserCourseInstanceChapterProgress> {
490 let course_instance =
491 crate::course_instances::get_course_instance(conn, course_instance_id).await?;
492 let mut exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
493
494 let exercise_ids: Vec<Uuid> = exercises.iter_mut().map(|e| e.id).collect();
495 let score_maximum: i32 = exercises.into_iter().map(|e| e.score_maximum).sum();
496
497 let user_chapter_metrics = crate::user_exercise_states::get_user_course_chapter_metrics(
498 conn,
499 course_instance.course_id,
500 &exercise_ids,
501 user_id,
502 )
503 .await?;
504
505 let result = UserCourseInstanceChapterProgress {
506 score_given: option_f32_to_f32_two_decimals_with_none_as_zero(
507 user_chapter_metrics.score_given,
508 ),
509 score_maximum,
510 total_exercises: Some(TryInto::try_into(exercise_ids.len())).transpose()?,
511 attempted_exercises: user_chapter_metrics
512 .attempted_exercises
513 .map(TryInto::try_into)
514 .transpose()?,
515 };
516 Ok(result)
517}
518
519pub async fn get_chapter_by_page_id(
520 conn: &mut PgConnection,
521 page_id: Uuid,
522) -> ModelResult<DatabaseChapter> {
523 let chapter = sqlx::query_as!(
524 DatabaseChapter,
525 "
526SELECT c.*
527FROM chapters c,
528 pages p
529WHERE c.id = p.chapter_id
530 AND p.id = $1
531 AND c.deleted_at IS NULL
532 ",
533 page_id
534 )
535 .fetch_one(conn)
536 .await?;
537
538 Ok(chapter)
539}
540
541pub async fn get_chapter_info_by_page_metadata(
542 conn: &mut PgConnection,
543 current_page_metadata: &PageMetadata,
544) -> ModelResult<ChapterInfo> {
545 let chapter_page = sqlx::query_as!(
546 ChapterInfo,
547 "
548 SELECT
549 c.id as chapter_id,
550 c.name as chapter_name,
551 c.front_page_id as chapter_front_page_id
552 FROM chapters c
553 WHERE c.id = $1
554 AND c.course_id = $2
555 AND c.deleted_at IS NULL;
556 ",
557 current_page_metadata.chapter_id,
558 current_page_metadata.course_id
559 )
560 .fetch_one(conn)
561 .await?;
562
563 Ok(chapter_page)
564}
565
566pub async fn set_module(
567 conn: &mut PgConnection,
568 chapter_id: Uuid,
569 module_id: Uuid,
570) -> ModelResult<()> {
571 sqlx::query!(
572 "
573UPDATE chapters
574SET course_module_id = $2
575WHERE id = $1
576",
577 chapter_id,
578 module_id
579 )
580 .execute(conn)
581 .await?;
582 Ok(())
583}
584
585pub async fn get_for_module(conn: &mut PgConnection, module_id: Uuid) -> ModelResult<Vec<Uuid>> {
586 let res = sqlx::query!(
587 "
588SELECT id
589FROM chapters
590WHERE course_module_id = $1
591AND deleted_at IS NULL
592",
593 module_id
594 )
595 .map(|c| c.id)
596 .fetch_all(conn)
597 .await?;
598 Ok(res)
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604
605 mod constraints {
606 use super::*;
607 use crate::{courses::NewCourse, library, test_helper::*};
608
609 #[tokio::test]
610 async fn cannot_create_chapter_for_different_course_than_its_module() {
611 insert_data!(:tx, :user, :org, course: course_1, instance: _instance, :course_module);
612 let course_2 = library::content_management::create_new_course(
613 tx.as_mut(),
614 PKeyPolicy::Generate,
615 NewCourse {
616 name: "".to_string(),
617 slug: "course-2".to_string(),
618 organization_id: org,
619 language_code: "en-US".to_string(),
620 teacher_in_charge_name: "Teacher".to_string(),
621 teacher_in_charge_email: "teacher@example.com".to_string(),
622 description: "".to_string(),
623 is_draft: false,
624 is_test_mode: false,
625 is_unlisted: false,
626 copy_user_permissions: false,
627 is_joinable_by_code_only: false,
628 join_code: None,
629 ask_marketing_consent: false,
630 flagged_answers_threshold: Some(3),
631 can_add_chatbot: false,
632 },
633 user,
634 |_, _, _| unimplemented!(),
635 |_| unimplemented!(),
636 )
637 .await
638 .unwrap()
639 .0
640 .id;
641 let chapter_result_2 = insert(
642 tx.as_mut(),
643 PKeyPolicy::Generate,
644 &NewChapter {
645 name: "Chapter of second course".to_string(),
646 color: None,
647 course_id: course_2,
648 chapter_number: 0,
649 front_page_id: None,
650 opens_at: None,
651 deadline: None,
652 course_module_id: Some(course_module.id),
653 },
654 )
655 .await;
656 assert!(
657 chapter_result_2.is_err(),
658 "Expected chapter creation to fail when course module belongs to a different course."
659 );
660 }
661 }
662}