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