1use std::path::PathBuf;
2
3use crate::{
4 course_modules, courses,
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 AND deleted_at IS NULL;",
247 chapter_id,
248 )
249 .fetch_optional(conn)
250 .await?;
251 chapter.ok_or_else(|| {
252 ModelError::new(
253 ModelErrorType::NotFound,
254 format!(
255 "Chapter with id {} not found or has been deleted",
256 chapter_id
257 ),
258 None,
259 )
260 })
261}
262
263pub async fn get_course_id(conn: &mut PgConnection, chapter_id: Uuid) -> ModelResult<Uuid> {
264 let course_id = sqlx::query!("SELECT course_id from chapters where id = $1", chapter_id)
265 .fetch_one(conn)
266 .await?
267 .course_id;
268 Ok(course_id)
269}
270
271pub async fn update_chapter(
272 conn: &mut PgConnection,
273 chapter_id: Uuid,
274 chapter_update: ChapterUpdate,
275) -> ModelResult<DatabaseChapter> {
276 let res = sqlx::query_as!(
277 DatabaseChapter,
278 r#"
279UPDATE chapters
280SET name = $2,
281 deadline = $3,
282 opens_at = $4,
283 course_module_id = $5,
284 color = $6
285WHERE id = $1
286RETURNING *;
287 "#,
288 chapter_id,
289 chapter_update.name,
290 chapter_update.deadline,
291 chapter_update.opens_at,
292 chapter_update.course_module_id,
293 chapter_update.color,
294 )
295 .fetch_one(conn)
296 .await?;
297 Ok(res)
298}
299
300pub async fn update_chapter_image_path(
301 conn: &mut PgConnection,
302 chapter_id: Uuid,
303 chapter_image_path: Option<String>,
304) -> ModelResult<DatabaseChapter> {
305 let updated_chapter = sqlx::query_as!(
306 DatabaseChapter,
307 "
308UPDATE chapters
309SET chapter_image_path = $1
310WHERE id = $2
311RETURNING *;",
312 chapter_image_path,
313 chapter_id
314 )
315 .fetch_one(conn)
316 .await?;
317 Ok(updated_chapter)
318}
319
320#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
321#[cfg_attr(feature = "ts_rs", derive(TS))]
322pub struct ChapterWithStatus {
323 pub id: Uuid,
324 pub created_at: DateTime<Utc>,
325 pub updated_at: DateTime<Utc>,
326 pub name: String,
327 pub color: Option<String>,
328 pub course_id: Uuid,
329 pub deleted_at: Option<DateTime<Utc>>,
330 pub chapter_number: i32,
331 pub front_page_id: Option<Uuid>,
332 pub opens_at: Option<DateTime<Utc>>,
333 pub status: ChapterStatus,
334 pub chapter_image_url: Option<String>,
335 pub course_module_id: Uuid,
336}
337
338impl ChapterWithStatus {
339 pub fn from_database_chapter_timestamp_and_image_url(
340 database_chapter: DatabaseChapter,
341 timestamp: DateTime<Utc>,
342 chapter_image_url: Option<String>,
343 ) -> Self {
344 let open = database_chapter
345 .opens_at
346 .map(|o| o <= timestamp)
347 .unwrap_or(true);
348 let status = if open {
349 ChapterStatus::Open
350 } else {
351 ChapterStatus::Closed
352 };
353 ChapterWithStatus {
354 id: database_chapter.id,
355 created_at: database_chapter.created_at,
356 updated_at: database_chapter.updated_at,
357 name: database_chapter.name,
358 color: database_chapter.color,
359 course_id: database_chapter.course_id,
360 deleted_at: database_chapter.deleted_at,
361 chapter_number: database_chapter.chapter_number,
362 front_page_id: database_chapter.front_page_id,
363 opens_at: database_chapter.opens_at,
364 status,
365 chapter_image_url,
366 course_module_id: database_chapter.course_module_id,
367 }
368 }
369}
370
371#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy)]
372#[cfg_attr(feature = "ts_rs", derive(TS))]
373pub struct UserCourseInstanceChapterProgress {
374 pub score_given: f32,
375 pub score_maximum: i32,
376 pub total_exercises: Option<u32>,
377 pub attempted_exercises: Option<u32>,
378}
379
380pub async fn course_chapters(
381 conn: &mut PgConnection,
382 course_id: Uuid,
383) -> ModelResult<Vec<DatabaseChapter>> {
384 let chapters = sqlx::query_as!(
385 DatabaseChapter,
386 r#"
387SELECT id,
388 created_at,
389 updated_at,
390 name,
391 color,
392 course_id,
393 deleted_at,
394 chapter_image_path,
395 chapter_number,
396 front_page_id,
397 opens_at,
398 copied_from,
399 deadline,
400 course_module_id
401FROM chapters
402WHERE course_id = $1
403 AND deleted_at IS NULL;
404"#,
405 course_id
406 )
407 .fetch_all(conn)
408 .await?;
409 Ok(chapters)
410}
411
412pub async fn course_instance_chapters(
413 conn: &mut PgConnection,
414 course_instance_id: Uuid,
415) -> ModelResult<Vec<DatabaseChapter>> {
416 let chapters = sqlx::query_as!(
417 DatabaseChapter,
418 r#"
419SELECT id,
420 created_at,
421 updated_at,
422 name,
423 color,
424 course_id,
425 deleted_at,
426 chapter_image_path,
427 chapter_number,
428 front_page_id,
429 opens_at,
430 copied_from,
431 deadline,
432 course_module_id
433FROM chapters
434WHERE course_id = (SELECT course_id FROM course_instances WHERE id = $1)
435 AND deleted_at IS NULL;
436"#,
437 course_instance_id
438 )
439 .fetch_all(conn)
440 .await?;
441 Ok(chapters)
442}
443
444pub async fn delete_chapter(
445 conn: &mut PgConnection,
446 chapter_id: Uuid,
447) -> ModelResult<DatabaseChapter> {
448 let mut tx = conn.begin().await?;
449 let deleted = sqlx::query_as!(
450 DatabaseChapter,
451 r#"
452UPDATE chapters
453SET deleted_at = now()
454WHERE id = $1
455AND deleted_at IS NULL
456RETURNING *;
457"#,
458 chapter_id
459 )
460 .fetch_one(&mut *tx)
461 .await?;
462 sqlx::query!(
464 "UPDATE pages SET deleted_at = now() WHERE chapter_id = $1 AND deleted_at IS NULL;",
465 chapter_id
466 )
467 .execute(&mut *tx)
468 .await?;
469 sqlx::query!(
470 "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));",
471 chapter_id
472 )
473 .execute(&mut *tx).await?;
474 sqlx::query!(
475 "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);",
476 chapter_id
477 )
478 .execute(&mut *tx).await?;
479 sqlx::query!(
480 "UPDATE exercises SET deleted_at = now() WHERE deleted_at IS NULL AND chapter_id = $1;",
481 chapter_id
482 )
483 .execute(&mut *tx)
484 .await?;
485 tx.commit().await?;
486 Ok(deleted)
487}
488
489pub async fn get_user_course_instance_chapter_progress(
490 conn: &mut PgConnection,
491 course_instance_id: Uuid,
492 chapter_id: Uuid,
493 user_id: Uuid,
494) -> ModelResult<UserCourseInstanceChapterProgress> {
495 let course_instance =
496 crate::course_instances::get_course_instance(conn, course_instance_id).await?;
497 let mut exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
498
499 let exercise_ids: Vec<Uuid> = exercises.iter_mut().map(|e| e.id).collect();
500 let score_maximum: i32 = exercises.into_iter().map(|e| e.score_maximum).sum();
501
502 let user_chapter_metrics = crate::user_exercise_states::get_user_course_chapter_metrics(
503 conn,
504 course_instance.course_id,
505 &exercise_ids,
506 user_id,
507 )
508 .await?;
509
510 let result = UserCourseInstanceChapterProgress {
511 score_given: option_f32_to_f32_two_decimals_with_none_as_zero(
512 user_chapter_metrics.score_given,
513 ),
514 score_maximum,
515 total_exercises: Some(TryInto::try_into(exercise_ids.len())).transpose()?,
516 attempted_exercises: user_chapter_metrics
517 .attempted_exercises
518 .map(TryInto::try_into)
519 .transpose()?,
520 };
521 Ok(result)
522}
523
524pub async fn get_chapter_by_page_id(
525 conn: &mut PgConnection,
526 page_id: Uuid,
527) -> ModelResult<DatabaseChapter> {
528 let chapter = sqlx::query_as!(
529 DatabaseChapter,
530 "
531SELECT c.*
532FROM chapters c,
533 pages p
534WHERE c.id = p.chapter_id
535 AND p.id = $1
536 AND c.deleted_at IS NULL
537 ",
538 page_id
539 )
540 .fetch_one(conn)
541 .await?;
542
543 Ok(chapter)
544}
545
546pub async fn get_chapter_info_by_page_metadata(
547 conn: &mut PgConnection,
548 current_page_metadata: &PageMetadata,
549) -> ModelResult<ChapterInfo> {
550 let chapter_page = sqlx::query_as!(
551 ChapterInfo,
552 "
553 SELECT
554 c.id as chapter_id,
555 c.name as chapter_name,
556 c.front_page_id as chapter_front_page_id
557 FROM chapters c
558 WHERE c.id = $1
559 AND c.course_id = $2
560 AND c.deleted_at IS NULL;
561 ",
562 current_page_metadata.chapter_id,
563 current_page_metadata.course_id
564 )
565 .fetch_one(conn)
566 .await?;
567
568 Ok(chapter_page)
569}
570
571pub async fn set_module(
572 conn: &mut PgConnection,
573 chapter_id: Uuid,
574 module_id: Uuid,
575) -> ModelResult<()> {
576 sqlx::query!(
577 "
578UPDATE chapters
579SET course_module_id = $2
580WHERE id = $1
581",
582 chapter_id,
583 module_id
584 )
585 .execute(conn)
586 .await?;
587 Ok(())
588}
589
590pub async fn get_for_module(conn: &mut PgConnection, module_id: Uuid) -> ModelResult<Vec<Uuid>> {
591 let res = sqlx::query!(
592 "
593SELECT id
594FROM chapters
595WHERE course_module_id = $1
596AND deleted_at IS NULL
597",
598 module_id
599 )
600 .map(|c| c.id)
601 .fetch_all(conn)
602 .await?;
603 Ok(res)
604}
605
606#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
607#[cfg_attr(feature = "ts_rs", derive(TS))]
608pub struct UserChapterProgress {
609 pub user_id: Uuid,
610 pub chapter_id: Uuid,
611 pub chapter_number: i32,
612 pub chapter_name: String,
613 pub points_obtained: f64,
614 pub exercises_attempted: i64,
615}
616
617#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
618#[cfg_attr(feature = "ts_rs", derive(TS))]
619pub struct ChapterAvailability {
620 pub chapter_id: Uuid,
621 pub chapter_number: i32,
622 pub chapter_name: String,
623 pub exercises_available: i64,
624 pub points_available: i64,
625}
626
627#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
628#[cfg_attr(feature = "ts_rs", derive(TS))]
629pub struct CourseUserInfo {
630 pub first_name: Option<String>,
631 pub last_name: Option<String>,
632 pub user_id: Uuid,
633 pub email: Option<String>,
634 pub course_instance: Option<String>,
635}
636
637pub async fn fetch_user_chapter_progress(
638 conn: &mut PgConnection,
639 course_id: Uuid,
640) -> ModelResult<Vec<UserChapterProgress>> {
641 let rows = sqlx::query_as!(
642 UserChapterProgress,
643 r#"
644WITH base AS (
645 SELECT ues.user_id,
646 ex.chapter_id,
647 ues.exercise_id,
648 COALESCE(ues.score_given, 0)::double precision AS points
649 FROM user_exercise_states ues
650 JOIN exercises ex ON ex.id = ues.exercise_id
651 WHERE ues.course_id = $1
652 AND ues.deleted_at IS NULL
653 AND ex.deleted_at IS NULL
654)
655SELECT b.user_id AS user_id,
656 c.id AS chapter_id,
657 c.chapter_number AS chapter_number,
658 c.name AS chapter_name,
659 COALESCE(SUM(b.points), 0)::double precision AS "points_obtained!",
660 COALESCE(COUNT(DISTINCT b.exercise_id), 0)::bigint AS "exercises_attempted!"
661FROM base b
662 JOIN chapters c ON c.id = b.chapter_id
663GROUP BY b.user_id,
664 c.id,
665 c.chapter_number,
666 c.name
667ORDER BY b.user_id,
668 c.chapter_number
669 "#,
670 course_id
671 )
672 .fetch_all(&mut *conn)
673 .await?;
674
675 Ok(rows)
676}
677
678pub async fn fetch_chapter_availability(
679 conn: &mut PgConnection,
680 course_id: Uuid,
681) -> ModelResult<Vec<ChapterAvailability>> {
682 let rows = sqlx::query_as!(
683 ChapterAvailability,
684 r#"
685SELECT c.id AS chapter_id,
686 c.chapter_number AS chapter_number,
687 c.name AS chapter_name,
688 COALESCE(COUNT(ex.id), 0)::bigint AS "exercises_available!",
689 COALESCE(COUNT(ex.id), 0)::bigint AS "points_available!"
690FROM chapters c
691 JOIN exercises ex ON ex.chapter_id = c.id
692WHERE c.course_id = $1
693 AND c.deleted_at IS NULL
694 AND ex.deleted_at IS NULL
695GROUP BY c.id,
696 c.chapter_number,
697 c.name
698ORDER BY c.chapter_number
699 "#,
700 course_id
701 )
702 .fetch_all(conn)
703 .await?;
704
705 Ok(rows)
706}
707
708pub async fn fetch_course_users(
709 conn: &mut PgConnection,
710 course_id: Uuid,
711) -> ModelResult<Vec<CourseUserInfo>> {
712 let rows_raw = sqlx::query!(
713 r#"
714 SELECT
715 ud.first_name,
716 ud.last_name,
717 u.id AS user_id,
718 ud.email AS "email?",
719 ci.name AS "course_instance?"
720 FROM course_instance_enrollments AS cie
721 JOIN users AS u ON u.id = cie.user_id
722 LEFT JOIN user_details AS ud ON ud.user_id = u.id
723 JOIN course_instances AS ci ON ci.id = cie.course_instance_id
724 WHERE cie.course_id = $1
725 AND cie.deleted_at IS NULL
726 ORDER BY 1, user_id
727 "#,
728 course_id
729 )
730 .fetch_all(conn)
731 .await?;
732
733 let rows = rows_raw
734 .into_iter()
735 .map(|r| {
736 let first_name = r
737 .first_name
738 .map(|f| f.trim().to_string())
739 .filter(|f| !f.is_empty());
740 let last_name = r
741 .last_name
742 .map(|l| l.trim().to_string())
743 .filter(|l| !l.is_empty());
744
745 CourseUserInfo {
746 first_name,
747 last_name,
748 user_id: r.user_id,
749 email: r.email,
750 course_instance: r.course_instance,
751 }
752 })
753 .collect();
754
755 Ok(rows)
756}
757
758#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
759#[cfg_attr(feature = "ts_rs", derive(TS))]
760pub struct UnreturnedExercise {
761 pub id: Uuid,
762 pub name: String,
763}
764
765#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
766#[cfg_attr(feature = "ts_rs", derive(TS))]
767pub struct ChapterLockPreview {
768 pub has_unreturned_exercises: bool,
769 pub unreturned_exercises_count: i32,
770 pub unreturned_exercises: Vec<UnreturnedExercise>,
771}
772
773pub async fn get_chapter_lock_preview(
774 conn: &mut PgConnection,
775 chapter_id: Uuid,
776 user_id: Uuid,
777 course_id: Uuid,
778) -> ModelResult<ChapterLockPreview> {
779 let exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
780
781 if exercises.is_empty() {
782 return Ok(ChapterLockPreview {
783 has_unreturned_exercises: false,
784 unreturned_exercises_count: 0,
785 unreturned_exercises: Vec::new(),
786 });
787 }
788
789 let exercise_ids: Vec<Uuid> = exercises.iter().map(|e| e.id).collect();
790
791 let returned_exercise_ids =
792 crate::user_exercise_states::get_returned_exercise_ids_for_user_and_course(
793 conn,
794 &exercise_ids,
795 user_id,
796 course_id,
797 )
798 .await?;
799
800 let returned_ids: std::collections::HashSet<Uuid> = returned_exercise_ids.into_iter().collect();
801
802 let unreturned_exercises: Vec<UnreturnedExercise> = exercises
803 .into_iter()
804 .filter(|e| !returned_ids.contains(&e.id))
805 .map(|e| UnreturnedExercise {
806 id: e.id,
807 name: e.name,
808 })
809 .collect();
810
811 let count = unreturned_exercises.len() as i32;
812 let has_unreturned = count > 0;
813
814 Ok(ChapterLockPreview {
815 has_unreturned_exercises: has_unreturned,
816 unreturned_exercises_count: count,
817 unreturned_exercises,
818 })
819}
820
821pub async fn get_previous_chapters_in_module(
822 conn: &mut PgConnection,
823 chapter_id: Uuid,
824) -> ModelResult<Vec<DatabaseChapter>> {
825 let chapter = get_chapter(conn, chapter_id).await?;
826 let previous_chapters = sqlx::query_as!(
827 DatabaseChapter,
828 r#"
829SELECT *
830FROM chapters
831WHERE course_module_id = $1
832 AND chapter_number < $2
833 AND deleted_at IS NULL
834ORDER BY chapter_number ASC
835 "#,
836 chapter.course_module_id,
837 chapter.chapter_number
838 )
839 .fetch_all(conn)
840 .await?;
841 Ok(previous_chapters)
842}
843
844pub async fn move_chapter_exercises_to_manual_review(
845 conn: &mut PgConnection,
846 chapter_id: Uuid,
847 user_id: Uuid,
848 course_id: Uuid,
849) -> ModelResult<()> {
850 use crate::CourseOrExamId;
851 use crate::exercises;
852 use crate::user_exercise_states::{self, ReviewingStage};
853
854 let exercises = exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
855
856 for exercise in exercises {
857 let user_exercise_state_result =
858 user_exercise_states::get_users_current_by_exercise(conn, user_id, &exercise).await;
859
860 if let Ok(user_exercise_state) = user_exercise_state_result
861 && user_exercise_state.reviewing_stage != ReviewingStage::WaitingForManualGrading
862 && user_exercise_state.reviewing_stage != ReviewingStage::ReviewedAndLocked
863 && user_exercise_state.selected_exercise_slide_id.is_some()
864 {
865 let course_or_exam_id = CourseOrExamId::Course(course_id);
866 user_exercise_states::update_reviewing_stage(
867 conn,
868 user_id,
869 course_or_exam_id,
870 exercise.id,
871 ReviewingStage::WaitingForManualGrading,
872 )
873 .await?;
874 }
875 }
876
877 Ok(())
878}
879
880pub async fn unlock_first_chapters_for_user(
883 conn: &mut PgConnection,
884 user_id: Uuid,
885 course_id: Uuid,
886) -> ModelResult<Vec<Uuid>> {
887 use crate::{course_modules, exercises, user_chapter_locking_statuses};
888
889 let all_modules = course_modules::get_by_course_id(conn, course_id).await?;
890 let base_module = all_modules
891 .into_iter()
892 .find(|m| m.order_number == 0)
893 .ok_or_else(|| {
894 ModelError::new(
895 ModelErrorType::NotFound,
896 "Base module not found".to_string(),
897 None,
898 )
899 })?;
900
901 let module_chapter_ids = get_for_module(conn, base_module.id).await?;
902 let mut module_chapters = course_chapters(conn, course_id)
903 .await?
904 .into_iter()
905 .filter(|c| module_chapter_ids.contains(&c.id))
906 .collect::<Vec<_>>();
907 module_chapters.sort_by_key(|c| c.chapter_number);
908
909 let mut chapters_to_unlock = Vec::new();
910
911 for chapter in &module_chapters {
912 let exercises = exercises::get_exercises_by_chapter_id(conn, chapter.id).await?;
913 let has_exercises = !exercises.is_empty();
914
915 if has_exercises {
916 chapters_to_unlock.push(chapter.id);
917 break;
918 } else {
919 chapters_to_unlock.push(chapter.id);
920 }
921 }
922
923 for chapter_id in &chapters_to_unlock {
924 user_chapter_locking_statuses::unlock_chapter(conn, user_id, *chapter_id, course_id)
925 .await?;
926 }
927
928 Ok(chapters_to_unlock)
929}
930
931pub async fn unlock_next_chapters_for_user(
937 conn: &mut PgConnection,
938 user_id: Uuid,
939 chapter_id: Uuid,
940 course_id: Uuid,
941) -> ModelResult<Vec<Uuid>> {
942 use crate::{course_modules, exercises, user_chapter_locking_statuses};
943
944 let completed_chapter = get_chapter(conn, chapter_id).await?;
945 let module = course_modules::get_by_id(conn, completed_chapter.course_module_id).await?;
946
947 let module_chapters = get_for_module(conn, completed_chapter.course_module_id).await?;
948 let mut all_module_chapters = course_chapters(conn, course_id)
949 .await?
950 .into_iter()
951 .filter(|c| module_chapters.contains(&c.id))
952 .collect::<Vec<_>>();
953 all_module_chapters.sort_by_key(|c| c.chapter_number);
954
955 let mut chapters_to_unlock = Vec::new();
956
957 let is_base_module = module.order_number == 0;
958
959 let course = courses::get_course(conn, course_id).await?;
960 let mut all_module_chapters_completed = true;
961 for chapter in &all_module_chapters {
962 let status = user_chapter_locking_statuses::get_or_init_status(
963 conn,
964 user_id,
965 chapter.id,
966 Some(course_id),
967 Some(course.chapter_locking_enabled),
968 )
969 .await?;
970 if !matches!(
971 status,
972 Some(user_chapter_locking_statuses::ChapterLockingStatus::CompletedAndLocked)
973 ) {
974 all_module_chapters_completed = false;
975 break;
976 }
977 }
978
979 if is_base_module && all_module_chapters_completed {
980 let all_modules = course_modules::get_by_course_id(conn, course_id).await?;
981 let additional_modules: Vec<_> = all_modules
982 .into_iter()
983 .filter(|m| m.order_number != 0)
984 .collect();
985
986 let mut all_additional_module_chapter_ids = Vec::new();
987 for additional_module in &additional_modules {
988 let module_chapter_ids = get_for_module(conn, additional_module.id).await?;
989 all_additional_module_chapter_ids.extend(module_chapter_ids);
990 }
991
992 let all_exercises = if !all_additional_module_chapter_ids.is_empty() {
993 exercises::get_exercises_by_chapter_ids(conn, &all_additional_module_chapter_ids)
994 .await?
995 } else {
996 Vec::new()
997 };
998
999 let exercises_by_chapter: std::collections::HashMap<Uuid, Vec<_>> = all_exercises
1000 .into_iter()
1001 .fold(std::collections::HashMap::new(), |mut acc, ex| {
1002 if let Some(ch_id) = ex.chapter_id {
1003 acc.entry(ch_id).or_insert_with(Vec::new).push(ex);
1004 }
1005 acc
1006 });
1007
1008 for additional_module in additional_modules {
1009 let module_chapter_ids = get_for_module(conn, additional_module.id).await?;
1010 let mut module_chapters = course_chapters(conn, course_id)
1011 .await?
1012 .into_iter()
1013 .filter(|c| module_chapter_ids.contains(&c.id))
1014 .collect::<Vec<_>>();
1015 module_chapters.sort_by_key(|c| c.chapter_number);
1016
1017 for chapter in &module_chapters {
1018 let has_exercises = exercises_by_chapter
1019 .get(&chapter.id)
1020 .map(|exs| !exs.is_empty())
1021 .unwrap_or(false);
1022
1023 if has_exercises {
1024 chapters_to_unlock.push(chapter.id);
1025 break;
1026 } else {
1027 chapters_to_unlock.push(chapter.id);
1028 }
1029 }
1030 }
1031 } else {
1032 let module_chapter_ids = get_for_module(conn, completed_chapter.course_module_id).await?;
1033 let mut module_chapters = course_chapters(conn, course_id)
1034 .await?
1035 .into_iter()
1036 .filter(|c| module_chapter_ids.contains(&c.id))
1037 .collect::<Vec<_>>();
1038 module_chapters.sort_by_key(|c| c.chapter_number);
1039 let mut found_completed = false;
1040 let mut candidate_chapter_ids = Vec::new();
1041
1042 for chapter in &module_chapters {
1043 if chapter.id == completed_chapter.id {
1044 found_completed = true;
1045 continue;
1046 }
1047
1048 if !found_completed {
1049 continue;
1050 }
1051
1052 candidate_chapter_ids.push(chapter.id);
1053 }
1054
1055 let all_exercises = if !candidate_chapter_ids.is_empty() {
1056 exercises::get_exercises_by_chapter_ids(conn, &candidate_chapter_ids).await?
1057 } else {
1058 Vec::new()
1059 };
1060
1061 let exercises_by_chapter: std::collections::HashMap<Uuid, Vec<_>> = all_exercises
1062 .into_iter()
1063 .fold(std::collections::HashMap::new(), |mut acc, ex| {
1064 if let Some(ch_id) = ex.chapter_id {
1065 acc.entry(ch_id).or_insert_with(Vec::new).push(ex);
1066 }
1067 acc
1068 });
1069
1070 for chapter_id in candidate_chapter_ids {
1071 let has_exercises = exercises_by_chapter
1072 .get(&chapter_id)
1073 .map(|exs| !exs.is_empty())
1074 .unwrap_or(false);
1075
1076 if has_exercises {
1077 chapters_to_unlock.push(chapter_id);
1078 break;
1079 } else {
1080 chapters_to_unlock.push(chapter_id);
1081 }
1082 }
1083 }
1084
1085 for chapter_id in &chapters_to_unlock {
1086 user_chapter_locking_statuses::unlock_chapter(conn, user_id, *chapter_id, course_id)
1087 .await?;
1088 }
1089
1090 Ok(chapters_to_unlock)
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096
1097 mod constraints {
1098 use super::*;
1099 use crate::{courses::NewCourse, library, test_helper::*};
1100
1101 #[tokio::test]
1102 async fn cannot_create_chapter_for_different_course_than_its_module() {
1103 insert_data!(:tx, :user, :org, course: course_1, instance: _instance, :course_module);
1104 let course_2 = library::content_management::create_new_course(
1105 tx.as_mut(),
1106 PKeyPolicy::Generate,
1107 NewCourse {
1108 name: "".to_string(),
1109 slug: "course-2".to_string(),
1110 organization_id: org,
1111 language_code: "en".to_string(),
1112 teacher_in_charge_name: "Teacher".to_string(),
1113 teacher_in_charge_email: "teacher@example.com".to_string(),
1114 description: "".to_string(),
1115 is_draft: false,
1116 is_test_mode: false,
1117 is_unlisted: false,
1118 copy_user_permissions: false,
1119 is_joinable_by_code_only: false,
1120 join_code: None,
1121 ask_marketing_consent: false,
1122 flagged_answers_threshold: Some(3),
1123 can_add_chatbot: false,
1124 },
1125 user,
1126 |_, _, _| unimplemented!(),
1127 |_| unimplemented!(),
1128 )
1129 .await
1130 .unwrap()
1131 .0
1132 .id;
1133 let chapter_result_2 = insert(
1134 tx.as_mut(),
1135 PKeyPolicy::Generate,
1136 &NewChapter {
1137 name: "Chapter of second course".to_string(),
1138 color: None,
1139 course_id: course_2,
1140 chapter_number: 0,
1141 front_page_id: None,
1142 opens_at: None,
1143 deadline: None,
1144 course_module_id: Some(course_module.id),
1145 },
1146 )
1147 .await;
1148 assert!(
1149 chapter_result_2.is_err(),
1150 "Expected chapter creation to fail when course module belongs to a different course."
1151 );
1152 }
1153 }
1154}