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