1use std::{collections::HashMap, path::PathBuf};
2
3use crate::CourseOrExamId;
4use crate::exercises;
5use crate::exercises::GradingProgress;
6use crate::library::user_exercise_state_updater;
7use crate::user_exercise_states::{self, ReviewingStage};
8use crate::{
9 course_modules, courses,
10 pages::{PageMetadata, PageWithExercises},
11 prelude::*,
12};
13use headless_lms_utils::{
14 ApplicationConfiguration, file_store::FileStore,
15 numbers::option_f32_to_f32_two_decimals_with_none_as_zero,
16};
17
18#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
19#[cfg_attr(feature = "ts_rs", derive(TS))]
20pub struct DatabaseChapter {
21 pub id: Uuid,
22 pub created_at: DateTime<Utc>,
23 pub updated_at: DateTime<Utc>,
24 pub name: String,
25 pub color: Option<String>,
26 pub course_id: Uuid,
27 pub deleted_at: Option<DateTime<Utc>>,
28 pub chapter_image_path: Option<String>,
29 pub chapter_number: i32,
30 pub front_page_id: Option<Uuid>,
31 pub opens_at: Option<DateTime<Utc>>,
32 pub deadline: Option<DateTime<Utc>>,
33 pub copied_from: Option<Uuid>,
34 pub course_module_id: Uuid,
35}
36
37impl DatabaseChapter {
38 pub fn has_opened(&self) -> bool {
40 self.opens_at
41 .map(|opens_at| opens_at < Utc::now())
42 .unwrap_or(true)
43 }
44}
45
46#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
47#[cfg_attr(feature = "ts_rs", derive(TS))]
48pub struct Chapter {
49 pub id: Uuid,
50 pub created_at: DateTime<Utc>,
51 pub updated_at: DateTime<Utc>,
52 pub name: String,
53 pub color: Option<String>,
54 pub course_id: Uuid,
55 pub deleted_at: Option<DateTime<Utc>>,
56 pub chapter_image_url: Option<String>,
57 pub chapter_number: i32,
58 pub front_page_id: Option<Uuid>,
59 pub opens_at: Option<DateTime<Utc>>,
60 pub deadline: Option<DateTime<Utc>>,
61 pub copied_from: Option<Uuid>,
62 pub course_module_id: Uuid,
63}
64
65impl Chapter {
66 pub fn from_database_chapter(
67 chapter: &DatabaseChapter,
68 file_store: &dyn FileStore,
69 app_conf: &ApplicationConfiguration,
70 ) -> Self {
71 let chapter_image_url = chapter.chapter_image_path.as_ref().map(|image| {
72 let path = PathBuf::from(image);
73 file_store.get_download_url(path.as_path(), app_conf)
74 });
75 Self {
76 id: chapter.id,
77 created_at: chapter.created_at,
78 updated_at: chapter.updated_at,
79 name: chapter.name.clone(),
80 color: chapter.color.clone(),
81 course_id: chapter.course_id,
82 deleted_at: chapter.deleted_at,
83 chapter_image_url,
84 chapter_number: chapter.chapter_number,
85 front_page_id: chapter.front_page_id,
86 opens_at: chapter.opens_at,
87 copied_from: chapter.copied_from,
88 deadline: chapter.deadline,
89 course_module_id: chapter.course_module_id,
90 }
91 }
92}
93
94#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
95#[cfg_attr(feature = "ts_rs", derive(TS))]
96#[serde(rename_all = "snake_case")]
97#[derive(Default)]
98pub enum ChapterStatus {
99 Open,
100 #[default]
101 Closed,
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 AND deleted_at IS NULL;",
252 chapter_id,
253 )
254 .fetch_optional(conn)
255 .await?;
256 chapter.ok_or_else(|| {
257 ModelError::new(
258 ModelErrorType::NotFound,
259 format!(
260 "Chapter with id {} not found or has been deleted",
261 chapter_id
262 ),
263 None,
264 )
265 })
266}
267
268pub async fn get_course_id(conn: &mut PgConnection, chapter_id: Uuid) -> ModelResult<Uuid> {
269 let course_id = sqlx::query!("SELECT course_id from chapters where id = $1", chapter_id)
270 .fetch_one(conn)
271 .await?
272 .course_id;
273 Ok(course_id)
274}
275
276pub async fn update_chapter(
277 conn: &mut PgConnection,
278 chapter_id: Uuid,
279 chapter_update: ChapterUpdate,
280) -> ModelResult<DatabaseChapter> {
281 let res = sqlx::query_as!(
282 DatabaseChapter,
283 r#"
284UPDATE chapters
285SET name = $2,
286 deadline = $3,
287 opens_at = $4,
288 course_module_id = $5,
289 color = $6
290WHERE id = $1
291RETURNING *;
292 "#,
293 chapter_id,
294 chapter_update.name,
295 chapter_update.deadline,
296 chapter_update.opens_at,
297 chapter_update.course_module_id,
298 chapter_update.color,
299 )
300 .fetch_one(conn)
301 .await?;
302 Ok(res)
303}
304
305pub async fn update_chapter_image_path(
306 conn: &mut PgConnection,
307 chapter_id: Uuid,
308 chapter_image_path: Option<String>,
309) -> ModelResult<DatabaseChapter> {
310 let updated_chapter = sqlx::query_as!(
311 DatabaseChapter,
312 "
313UPDATE chapters
314SET chapter_image_path = $1
315WHERE id = $2
316RETURNING *;",
317 chapter_image_path,
318 chapter_id
319 )
320 .fetch_one(conn)
321 .await?;
322 Ok(updated_chapter)
323}
324
325#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
326#[cfg_attr(feature = "ts_rs", derive(TS))]
327pub struct ChapterWithStatus {
328 pub id: Uuid,
329 pub created_at: DateTime<Utc>,
330 pub updated_at: DateTime<Utc>,
331 pub name: String,
332 pub color: Option<String>,
333 pub course_id: Uuid,
334 pub deleted_at: Option<DateTime<Utc>>,
335 pub chapter_number: i32,
336 pub front_page_id: Option<Uuid>,
337 pub opens_at: Option<DateTime<Utc>>,
338 pub deadline: Option<DateTime<Utc>>,
339 pub status: ChapterStatus,
340 pub chapter_image_url: Option<String>,
341 pub course_module_id: Uuid,
342 pub exercise_deadline_override_count: i64,
343 pub exercise_deadline_override_distinct_count: i64,
344 pub earliest_exercise_deadline_override: Option<DateTime<Utc>>,
345}
346
347#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, Default)]
348#[cfg_attr(feature = "ts_rs", derive(TS))]
349pub struct ChapterExerciseDeadlineOverrideSummary {
350 pub earliest_exercise_deadline_override: Option<DateTime<Utc>>,
351 pub exercise_deadline_override_count: i64,
352 pub exercise_deadline_override_distinct_count: i64,
353}
354
355impl ChapterWithStatus {
356 pub fn from_database_chapter_timestamp_and_image_url(
357 database_chapter: DatabaseChapter,
358 timestamp: DateTime<Utc>,
359 chapter_image_url: Option<String>,
360 exercise_deadline_overrides: Option<ChapterExerciseDeadlineOverrideSummary>,
361 ) -> Self {
362 let open = database_chapter
363 .opens_at
364 .map(|o| o <= timestamp)
365 .unwrap_or(true);
366 let status = if open {
367 ChapterStatus::Open
368 } else {
369 ChapterStatus::Closed
370 };
371 let exercise_deadline_overrides = exercise_deadline_overrides.unwrap_or_default();
372 ChapterWithStatus {
373 id: database_chapter.id,
374 created_at: database_chapter.created_at,
375 updated_at: database_chapter.updated_at,
376 name: database_chapter.name,
377 color: database_chapter.color,
378 course_id: database_chapter.course_id,
379 deleted_at: database_chapter.deleted_at,
380 chapter_number: database_chapter.chapter_number,
381 front_page_id: database_chapter.front_page_id,
382 opens_at: database_chapter.opens_at,
383 deadline: database_chapter.deadline,
384 status,
385 chapter_image_url,
386 course_module_id: database_chapter.course_module_id,
387 exercise_deadline_override_count: exercise_deadline_overrides
388 .exercise_deadline_override_count,
389 exercise_deadline_override_distinct_count: exercise_deadline_overrides
390 .exercise_deadline_override_distinct_count,
391 earliest_exercise_deadline_override: exercise_deadline_overrides
392 .earliest_exercise_deadline_override,
393 }
394 }
395}
396
397#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy)]
398#[cfg_attr(feature = "ts_rs", derive(TS))]
399pub struct UserCourseInstanceChapterProgress {
400 pub score_given: f32,
401 pub score_maximum: i32,
402 pub total_exercises: Option<u32>,
403 pub attempted_exercises: Option<u32>,
404}
405
406pub async fn course_chapters(
407 conn: &mut PgConnection,
408 course_id: Uuid,
409) -> ModelResult<Vec<DatabaseChapter>> {
410 let chapters = sqlx::query_as!(
411 DatabaseChapter,
412 r#"
413SELECT id,
414 created_at,
415 updated_at,
416 name,
417 color,
418 course_id,
419 deleted_at,
420 chapter_image_path,
421 chapter_number,
422 front_page_id,
423 opens_at,
424 copied_from,
425 deadline,
426 course_module_id
427FROM chapters
428WHERE course_id = $1
429 AND deleted_at IS NULL;
430"#,
431 course_id
432 )
433 .fetch_all(conn)
434 .await?;
435 Ok(chapters)
436}
437
438pub async fn exercise_deadline_overrides_by_chapter_for_course(
439 conn: &mut PgConnection,
440 course_id: Uuid,
441) -> ModelResult<HashMap<Uuid, ChapterExerciseDeadlineOverrideSummary>> {
442 let rows = sqlx::query!(
443 r#"
444SELECT
445 e.chapter_id,
446 MIN(COALESCE(e.deadline, c.deadline)) FILTER (
447 WHERE COALESCE(e.deadline, c.deadline) IS NOT NULL
448 ) AS earliest_exercise_deadline_override,
449 COUNT(*) FILTER (
450 WHERE e.deadline IS NOT NULL
451 AND (c.deadline IS NULL OR e.deadline <> c.deadline)
452 ) AS exercise_deadline_override_count,
453 COUNT(DISTINCT COALESCE(e.deadline, c.deadline)) FILTER (
454 WHERE COALESCE(e.deadline, c.deadline) IS NOT NULL
455 ) AS exercise_deadline_override_distinct_count
456FROM exercises e
457JOIN chapters c ON c.id = e.chapter_id
458WHERE c.course_id = $1
459 AND c.deleted_at IS NULL
460 AND e.deleted_at IS NULL
461GROUP BY e.chapter_id, c.deadline
462 "#,
463 course_id
464 )
465 .fetch_all(conn)
466 .await?;
467
468 let mut summaries = HashMap::new();
469 for row in rows {
470 if let Some(chapter_id) = row.chapter_id {
471 summaries.insert(
472 chapter_id,
473 ChapterExerciseDeadlineOverrideSummary {
474 earliest_exercise_deadline_override: row.earliest_exercise_deadline_override,
475 exercise_deadline_override_count: row
476 .exercise_deadline_override_count
477 .unwrap_or(0),
478 exercise_deadline_override_distinct_count: row
479 .exercise_deadline_override_distinct_count
480 .unwrap_or(0),
481 },
482 );
483 }
484 }
485 Ok(summaries)
486}
487
488pub async fn course_instance_chapters(
489 conn: &mut PgConnection,
490 course_instance_id: Uuid,
491) -> ModelResult<Vec<DatabaseChapter>> {
492 let chapters = sqlx::query_as!(
493 DatabaseChapter,
494 r#"
495SELECT id,
496 created_at,
497 updated_at,
498 name,
499 color,
500 course_id,
501 deleted_at,
502 chapter_image_path,
503 chapter_number,
504 front_page_id,
505 opens_at,
506 copied_from,
507 deadline,
508 course_module_id
509FROM chapters
510WHERE course_id = (SELECT course_id FROM course_instances WHERE id = $1)
511 AND deleted_at IS NULL;
512"#,
513 course_instance_id
514 )
515 .fetch_all(conn)
516 .await?;
517 Ok(chapters)
518}
519
520pub async fn delete_chapter(
521 conn: &mut PgConnection,
522 chapter_id: Uuid,
523) -> ModelResult<DatabaseChapter> {
524 let mut tx = conn.begin().await?;
525 let deleted = sqlx::query_as!(
526 DatabaseChapter,
527 r#"
528UPDATE chapters
529SET deleted_at = now()
530WHERE id = $1
531AND deleted_at IS NULL
532RETURNING *;
533"#,
534 chapter_id
535 )
536 .fetch_one(&mut *tx)
537 .await?;
538 sqlx::query!(
540 "UPDATE pages SET deleted_at = now() WHERE chapter_id = $1 AND deleted_at IS NULL;",
541 chapter_id
542 )
543 .execute(&mut *tx)
544 .await?;
545 sqlx::query!(
546 "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));",
547 chapter_id
548 )
549 .execute(&mut *tx).await?;
550 sqlx::query!(
551 "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);",
552 chapter_id
553 )
554 .execute(&mut *tx).await?;
555 sqlx::query!(
556 "UPDATE exercises SET deleted_at = now() WHERE deleted_at IS NULL AND chapter_id = $1;",
557 chapter_id
558 )
559 .execute(&mut *tx)
560 .await?;
561 tx.commit().await?;
562 Ok(deleted)
563}
564
565pub async fn get_user_course_instance_chapter_progress(
566 conn: &mut PgConnection,
567 course_instance_id: Uuid,
568 chapter_id: Uuid,
569 user_id: Uuid,
570) -> ModelResult<UserCourseInstanceChapterProgress> {
571 let course_instance =
572 crate::course_instances::get_course_instance(conn, course_instance_id).await?;
573 let mut exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
574
575 let exercise_ids: Vec<Uuid> = exercises.iter_mut().map(|e| e.id).collect();
576 let score_maximum: i32 = exercises.into_iter().map(|e| e.score_maximum).sum();
577
578 let user_chapter_metrics = crate::user_exercise_states::get_user_course_chapter_metrics(
579 conn,
580 course_instance.course_id,
581 &exercise_ids,
582 user_id,
583 )
584 .await?;
585
586 let result = UserCourseInstanceChapterProgress {
587 score_given: option_f32_to_f32_two_decimals_with_none_as_zero(
588 user_chapter_metrics.score_given,
589 ),
590 score_maximum,
591 total_exercises: Some(TryInto::try_into(exercise_ids.len())).transpose()?,
592 attempted_exercises: user_chapter_metrics
593 .attempted_exercises
594 .map(TryInto::try_into)
595 .transpose()?,
596 };
597 Ok(result)
598}
599
600pub async fn get_chapter_by_page_id(
601 conn: &mut PgConnection,
602 page_id: Uuid,
603) -> ModelResult<DatabaseChapter> {
604 let chapter = sqlx::query_as!(
605 DatabaseChapter,
606 "
607SELECT c.*
608FROM chapters c,
609 pages p
610WHERE c.id = p.chapter_id
611 AND p.id = $1
612 AND c.deleted_at IS NULL
613 ",
614 page_id
615 )
616 .fetch_one(conn)
617 .await?;
618
619 Ok(chapter)
620}
621
622pub async fn get_chapter_info_by_page_metadata(
623 conn: &mut PgConnection,
624 current_page_metadata: &PageMetadata,
625) -> ModelResult<ChapterInfo> {
626 let chapter_page = sqlx::query_as!(
627 ChapterInfo,
628 "
629 SELECT
630 c.id as chapter_id,
631 c.name as chapter_name,
632 c.front_page_id as chapter_front_page_id
633 FROM chapters c
634 WHERE c.id = $1
635 AND c.course_id = $2
636 AND c.deleted_at IS NULL;
637 ",
638 current_page_metadata.chapter_id,
639 current_page_metadata.course_id
640 )
641 .fetch_one(conn)
642 .await?;
643
644 Ok(chapter_page)
645}
646
647pub async fn set_module(
648 conn: &mut PgConnection,
649 chapter_id: Uuid,
650 module_id: Uuid,
651) -> ModelResult<()> {
652 sqlx::query!(
653 "
654UPDATE chapters
655SET course_module_id = $2
656WHERE id = $1
657",
658 chapter_id,
659 module_id
660 )
661 .execute(conn)
662 .await?;
663 Ok(())
664}
665
666pub async fn get_for_module(conn: &mut PgConnection, module_id: Uuid) -> ModelResult<Vec<Uuid>> {
667 let res = sqlx::query!(
668 "
669SELECT id
670FROM chapters
671WHERE course_module_id = $1
672AND deleted_at IS NULL
673",
674 module_id
675 )
676 .map(|c| c.id)
677 .fetch_all(conn)
678 .await?;
679 Ok(res)
680}
681
682#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
683#[cfg_attr(feature = "ts_rs", derive(TS))]
684pub struct UserChapterProgress {
685 pub user_id: Uuid,
686 pub chapter_id: Uuid,
687 pub chapter_number: i32,
688 pub chapter_name: String,
689 pub points_obtained: f64,
690 pub exercises_attempted: i64,
691}
692
693#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
694#[cfg_attr(feature = "ts_rs", derive(TS))]
695pub struct ChapterAvailability {
696 pub chapter_id: Uuid,
697 pub chapter_number: i32,
698 pub chapter_name: String,
699 pub exercises_available: i64,
700 pub points_available: i64,
701}
702
703#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
704#[cfg_attr(feature = "ts_rs", derive(TS))]
705pub struct CourseUserInfo {
706 pub first_name: Option<String>,
707 pub last_name: Option<String>,
708 pub user_id: Uuid,
709 pub email: Option<String>,
710 pub course_instance: Option<String>,
711}
712
713pub async fn fetch_user_chapter_progress(
714 conn: &mut PgConnection,
715 course_id: Uuid,
716) -> ModelResult<Vec<UserChapterProgress>> {
717 let rows = sqlx::query_as!(
718 UserChapterProgress,
719 r#"
720WITH base AS (
721 SELECT ues.user_id,
722 ex.chapter_id,
723 ues.exercise_id,
724 COALESCE(ues.score_given, 0)::double precision AS points
725 FROM user_exercise_states ues
726 JOIN exercises ex ON ex.id = ues.exercise_id
727 WHERE ues.course_id = $1
728 AND ues.deleted_at IS NULL
729 AND ex.deleted_at IS NULL
730)
731SELECT b.user_id AS user_id,
732 c.id AS chapter_id,
733 c.chapter_number AS chapter_number,
734 c.name AS chapter_name,
735 COALESCE(SUM(b.points), 0)::double precision AS "points_obtained!",
736 COALESCE(COUNT(DISTINCT b.exercise_id), 0)::bigint AS "exercises_attempted!"
737FROM base b
738 JOIN chapters c ON c.id = b.chapter_id
739GROUP BY b.user_id,
740 c.id,
741 c.chapter_number,
742 c.name
743ORDER BY b.user_id,
744 c.chapter_number
745 "#,
746 course_id
747 )
748 .fetch_all(&mut *conn)
749 .await?;
750
751 Ok(rows)
752}
753
754pub async fn fetch_chapter_availability(
755 conn: &mut PgConnection,
756 course_id: Uuid,
757) -> ModelResult<Vec<ChapterAvailability>> {
758 let rows = sqlx::query_as!(
759 ChapterAvailability,
760 r#"
761SELECT c.id AS chapter_id,
762 c.chapter_number AS chapter_number,
763 c.name AS chapter_name,
764 COALESCE(COUNT(ex.id), 0)::bigint AS "exercises_available!",
765 COALESCE(COUNT(ex.id), 0)::bigint AS "points_available!"
766FROM chapters c
767 JOIN exercises ex ON ex.chapter_id = c.id
768WHERE c.course_id = $1
769 AND c.deleted_at IS NULL
770 AND ex.deleted_at IS NULL
771GROUP BY c.id,
772 c.chapter_number,
773 c.name
774ORDER BY c.chapter_number
775 "#,
776 course_id
777 )
778 .fetch_all(conn)
779 .await?;
780
781 Ok(rows)
782}
783
784pub async fn fetch_course_users(
785 conn: &mut PgConnection,
786 course_id: Uuid,
787) -> ModelResult<Vec<CourseUserInfo>> {
788 let rows_raw = sqlx::query!(
789 r#"
790 SELECT
791 ud.first_name,
792 ud.last_name,
793 u.id AS user_id,
794 ud.email AS "email?",
795 ci.name AS "course_instance?"
796 FROM course_instance_enrollments AS cie
797 JOIN users AS u ON u.id = cie.user_id
798 LEFT JOIN user_details AS ud ON ud.user_id = u.id
799 JOIN course_instances AS ci ON ci.id = cie.course_instance_id
800 WHERE cie.course_id = $1
801 AND cie.deleted_at IS NULL
802 ORDER BY 1, user_id
803 "#,
804 course_id
805 )
806 .fetch_all(conn)
807 .await?;
808
809 let rows = rows_raw
810 .into_iter()
811 .map(|r| {
812 let first_name = r
813 .first_name
814 .map(|f| f.trim().to_string())
815 .filter(|f| !f.is_empty());
816 let last_name = r
817 .last_name
818 .map(|l| l.trim().to_string())
819 .filter(|l| !l.is_empty());
820
821 CourseUserInfo {
822 first_name,
823 last_name,
824 user_id: r.user_id,
825 email: r.email,
826 course_instance: r.course_instance,
827 }
828 })
829 .collect();
830
831 Ok(rows)
832}
833
834#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
835#[cfg_attr(feature = "ts_rs", derive(TS))]
836pub struct UnreturnedExercise {
837 pub id: Uuid,
838 pub name: String,
839}
840
841#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
842#[cfg_attr(feature = "ts_rs", derive(TS))]
843pub struct ChapterLockPreview {
844 pub has_unreturned_exercises: bool,
845 pub unreturned_exercises_count: i32,
846 pub unreturned_exercises: Vec<UnreturnedExercise>,
847}
848
849pub async fn get_chapter_lock_preview(
850 conn: &mut PgConnection,
851 chapter_id: Uuid,
852 user_id: Uuid,
853 course_id: Uuid,
854) -> ModelResult<ChapterLockPreview> {
855 let exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
856
857 if exercises.is_empty() {
858 return Ok(ChapterLockPreview {
859 has_unreturned_exercises: false,
860 unreturned_exercises_count: 0,
861 unreturned_exercises: Vec::new(),
862 });
863 }
864
865 let exercise_ids: Vec<Uuid> = exercises.iter().map(|e| e.id).collect();
866
867 let returned_exercise_ids =
868 crate::user_exercise_states::get_returned_exercise_ids_for_user_and_course(
869 conn,
870 &exercise_ids,
871 user_id,
872 course_id,
873 )
874 .await?;
875
876 let returned_ids: std::collections::HashSet<Uuid> = returned_exercise_ids.into_iter().collect();
877
878 let unreturned_exercises: Vec<UnreturnedExercise> = exercises
879 .into_iter()
880 .filter(|e| !returned_ids.contains(&e.id))
881 .map(|e| UnreturnedExercise {
882 id: e.id,
883 name: e.name,
884 })
885 .collect();
886
887 let count = unreturned_exercises.len() as i32;
888 let has_unreturned = count > 0;
889
890 Ok(ChapterLockPreview {
891 has_unreturned_exercises: has_unreturned,
892 unreturned_exercises_count: count,
893 unreturned_exercises,
894 })
895}
896
897pub async fn get_previous_chapters_in_module(
898 conn: &mut PgConnection,
899 chapter_id: Uuid,
900) -> ModelResult<Vec<DatabaseChapter>> {
901 let chapter = get_chapter(conn, chapter_id).await?;
902 let previous_chapters = sqlx::query_as!(
903 DatabaseChapter,
904 r#"
905SELECT *
906FROM chapters
907WHERE course_module_id = $1
908 AND chapter_number < $2
909 AND deleted_at IS NULL
910ORDER BY chapter_number ASC
911 "#,
912 chapter.course_module_id,
913 chapter.chapter_number
914 )
915 .fetch_all(conn)
916 .await?;
917 Ok(previous_chapters)
918}
919
920pub async fn move_chapter_exercises_to_manual_review(
921 conn: &mut PgConnection,
922 chapter_id: Uuid,
923 user_id: Uuid,
924 course_id: Uuid,
925) -> ModelResult<()> {
926 let exercises = exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
927
928 for exercise in exercises {
929 let user_exercise_state_result =
930 user_exercise_states::get_users_current_by_exercise(conn, user_id, &exercise).await;
931
932 let user_exercise_state = match user_exercise_state_result {
933 Ok(state) => state,
934 Err(e) => {
935 if matches!(
936 e.error_type(),
937 ModelErrorType::PreconditionFailed | ModelErrorType::RecordNotFound
938 ) {
939 continue;
940 }
941 return Err(e);
942 }
943 };
944 if user_exercise_state.reviewing_stage == ReviewingStage::WaitingForManualGrading
945 || user_exercise_state.reviewing_stage == ReviewingStage::ReviewedAndLocked
946 || user_exercise_state.reviewing_stage == ReviewingStage::Locked
947 || user_exercise_state.selected_exercise_slide_id.is_none()
948 {
949 continue;
950 }
951
952 if exercise.needs_peer_review || exercise.needs_self_review {
953 user_exercise_states::update_reviewing_stage(
954 conn,
955 user_id,
956 CourseOrExamId::Course(course_id),
957 exercise.id,
958 ReviewingStage::WaitingForManualGrading,
959 )
960 .await?;
961 continue;
962 }
963
964 if !exercise.teacher_reviews_answer_after_locking
965 && user_exercise_state.grading_progress == GradingProgress::FullyGraded
966 {
967 user_exercise_states::update_reviewing_stage(
968 conn,
969 user_id,
970 CourseOrExamId::Course(course_id),
971 exercise.id,
972 ReviewingStage::Locked,
973 )
974 .await?;
975 user_exercise_state_updater::update_user_exercise_state(conn, user_exercise_state.id)
976 .await?;
977 continue;
978 }
979
980 user_exercise_states::update_reviewing_stage(
981 conn,
982 user_id,
983 CourseOrExamId::Course(course_id),
984 exercise.id,
985 ReviewingStage::WaitingForManualGrading,
986 )
987 .await?;
988 }
989
990 Ok(())
991}
992
993pub async fn unlock_first_chapters_for_user(
996 conn: &mut PgConnection,
997 user_id: Uuid,
998 course_id: Uuid,
999) -> ModelResult<Vec<Uuid>> {
1000 use crate::{course_modules, exercises, user_chapter_locking_statuses};
1001
1002 let all_modules = course_modules::get_by_course_id(conn, course_id).await?;
1003 let base_module = all_modules
1004 .into_iter()
1005 .find(|m| m.order_number == 0)
1006 .ok_or_else(|| {
1007 ModelError::new(
1008 ModelErrorType::NotFound,
1009 "Base module not found".to_string(),
1010 None,
1011 )
1012 })?;
1013
1014 let module_chapter_ids = get_for_module(conn, base_module.id).await?;
1015 let mut module_chapters = course_chapters(conn, course_id)
1016 .await?
1017 .into_iter()
1018 .filter(|c| module_chapter_ids.contains(&c.id))
1019 .collect::<Vec<_>>();
1020 module_chapters.sort_by_key(|c| c.chapter_number);
1021
1022 let mut chapters_to_unlock = Vec::new();
1023
1024 for chapter in &module_chapters {
1025 let exercises = exercises::get_exercises_by_chapter_id(conn, chapter.id).await?;
1026 let has_exercises = !exercises.is_empty();
1027
1028 if has_exercises {
1029 chapters_to_unlock.push(chapter.id);
1030 break;
1031 } else {
1032 chapters_to_unlock.push(chapter.id);
1033 }
1034 }
1035
1036 for chapter_id in &chapters_to_unlock {
1037 user_chapter_locking_statuses::unlock_chapter(conn, user_id, *chapter_id, course_id)
1038 .await?;
1039 }
1040
1041 Ok(chapters_to_unlock)
1042}
1043
1044pub async fn unlock_next_chapters_for_user(
1050 conn: &mut PgConnection,
1051 user_id: Uuid,
1052 chapter_id: Uuid,
1053 course_id: Uuid,
1054) -> ModelResult<Vec<Uuid>> {
1055 use crate::{course_modules, exercises, user_chapter_locking_statuses};
1056
1057 let completed_chapter = get_chapter(conn, chapter_id).await?;
1058 let module = course_modules::get_by_id(conn, completed_chapter.course_module_id).await?;
1059
1060 let module_chapters = get_for_module(conn, completed_chapter.course_module_id).await?;
1061 let mut all_module_chapters = course_chapters(conn, course_id)
1062 .await?
1063 .into_iter()
1064 .filter(|c| module_chapters.contains(&c.id))
1065 .collect::<Vec<_>>();
1066 all_module_chapters.sort_by_key(|c| c.chapter_number);
1067
1068 let mut chapters_to_unlock = Vec::new();
1069
1070 let is_base_module = module.order_number == 0;
1071
1072 let course = courses::get_course(conn, course_id).await?;
1073 let mut all_module_chapters_completed = true;
1074 for chapter in &all_module_chapters {
1075 let status = user_chapter_locking_statuses::get_or_init_status(
1076 conn,
1077 user_id,
1078 chapter.id,
1079 Some(course_id),
1080 Some(course.chapter_locking_enabled),
1081 )
1082 .await?;
1083 if !matches!(
1084 status,
1085 Some(user_chapter_locking_statuses::ChapterLockingStatus::CompletedAndLocked)
1086 ) {
1087 all_module_chapters_completed = false;
1088 break;
1089 }
1090 }
1091
1092 if is_base_module && all_module_chapters_completed {
1093 let all_modules = course_modules::get_by_course_id(conn, course_id).await?;
1094 let additional_modules: Vec<_> = all_modules
1095 .into_iter()
1096 .filter(|m| m.order_number != 0)
1097 .collect();
1098
1099 let mut all_additional_module_chapter_ids = Vec::new();
1100 for additional_module in &additional_modules {
1101 let module_chapter_ids = get_for_module(conn, additional_module.id).await?;
1102 all_additional_module_chapter_ids.extend(module_chapter_ids);
1103 }
1104
1105 let all_exercises = if !all_additional_module_chapter_ids.is_empty() {
1106 exercises::get_exercises_by_chapter_ids(conn, &all_additional_module_chapter_ids)
1107 .await?
1108 } else {
1109 Vec::new()
1110 };
1111
1112 let exercises_by_chapter: std::collections::HashMap<Uuid, Vec<_>> = all_exercises
1113 .into_iter()
1114 .fold(std::collections::HashMap::new(), |mut acc, ex| {
1115 if let Some(ch_id) = ex.chapter_id {
1116 acc.entry(ch_id).or_insert_with(Vec::new).push(ex);
1117 }
1118 acc
1119 });
1120
1121 for additional_module in additional_modules {
1122 let module_chapter_ids = get_for_module(conn, additional_module.id).await?;
1123 let mut module_chapters = course_chapters(conn, course_id)
1124 .await?
1125 .into_iter()
1126 .filter(|c| module_chapter_ids.contains(&c.id))
1127 .collect::<Vec<_>>();
1128 module_chapters.sort_by_key(|c| c.chapter_number);
1129
1130 for chapter in &module_chapters {
1131 let has_exercises = exercises_by_chapter
1132 .get(&chapter.id)
1133 .map(|exs| !exs.is_empty())
1134 .unwrap_or(false);
1135
1136 if has_exercises {
1137 chapters_to_unlock.push(chapter.id);
1138 break;
1139 } else {
1140 chapters_to_unlock.push(chapter.id);
1141 }
1142 }
1143 }
1144 } else {
1145 let module_chapter_ids = get_for_module(conn, completed_chapter.course_module_id).await?;
1146 let mut module_chapters = course_chapters(conn, course_id)
1147 .await?
1148 .into_iter()
1149 .filter(|c| module_chapter_ids.contains(&c.id))
1150 .collect::<Vec<_>>();
1151 module_chapters.sort_by_key(|c| c.chapter_number);
1152 let mut found_completed = false;
1153 let mut candidate_chapter_ids = Vec::new();
1154
1155 for chapter in &module_chapters {
1156 if chapter.id == completed_chapter.id {
1157 found_completed = true;
1158 continue;
1159 }
1160
1161 if !found_completed {
1162 continue;
1163 }
1164
1165 candidate_chapter_ids.push(chapter.id);
1166 }
1167
1168 let all_exercises = if !candidate_chapter_ids.is_empty() {
1169 exercises::get_exercises_by_chapter_ids(conn, &candidate_chapter_ids).await?
1170 } else {
1171 Vec::new()
1172 };
1173
1174 let exercises_by_chapter: std::collections::HashMap<Uuid, Vec<_>> = all_exercises
1175 .into_iter()
1176 .fold(std::collections::HashMap::new(), |mut acc, ex| {
1177 if let Some(ch_id) = ex.chapter_id {
1178 acc.entry(ch_id).or_insert_with(Vec::new).push(ex);
1179 }
1180 acc
1181 });
1182
1183 for chapter_id in candidate_chapter_ids {
1184 let has_exercises = exercises_by_chapter
1185 .get(&chapter_id)
1186 .map(|exs| !exs.is_empty())
1187 .unwrap_or(false);
1188
1189 if has_exercises {
1190 chapters_to_unlock.push(chapter_id);
1191 break;
1192 } else {
1193 chapters_to_unlock.push(chapter_id);
1194 }
1195 }
1196 }
1197
1198 for chapter_id in &chapters_to_unlock {
1199 user_chapter_locking_statuses::unlock_chapter(conn, user_id, *chapter_id, course_id)
1200 .await?;
1201 }
1202
1203 Ok(chapters_to_unlock)
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209
1210 mod move_chapter_exercises_to_manual_review {
1211 use super::*;
1212 use crate::{
1213 exercises::ActivityProgress,
1214 test_helper::*,
1215 user_exercise_slide_states,
1216 user_exercise_states::{self, UserExerciseStateUpdate},
1217 };
1218
1219 #[tokio::test]
1220 async fn fully_graded_auto_review_exercise_becomes_locked() {
1221 insert_data!(
1222 :tx,
1223 :user,
1224 :org,
1225 :course,
1226 instance: _instance,
1227 :course_module,
1228 :chapter,
1229 :page,
1230 :exercise,
1231 :slide
1232 );
1233
1234 exercises::update_teacher_reviews_answer_after_locking(tx.as_mut(), exercise, false)
1235 .await
1236 .unwrap();
1237
1238 user_exercise_states::upsert_selected_exercise_slide_id(
1239 tx.as_mut(),
1240 user,
1241 exercise,
1242 Some(course),
1243 None,
1244 Some(slide),
1245 )
1246 .await
1247 .unwrap();
1248
1249 let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
1250 tx.as_mut(),
1251 user,
1252 exercise,
1253 Some(course),
1254 None,
1255 )
1256 .await
1257 .unwrap();
1258
1259 let user_exercise_slide_state =
1260 user_exercise_slide_states::get_or_insert_by_unique_index(
1261 tx.as_mut(),
1262 user_exercise_state.id,
1263 slide,
1264 )
1265 .await
1266 .unwrap();
1267 user_exercise_slide_states::update(
1268 tx.as_mut(),
1269 user_exercise_slide_state.id,
1270 Some(1.0),
1271 GradingProgress::FullyGraded,
1272 )
1273 .await
1274 .unwrap();
1275
1276 user_exercise_states::update(
1277 tx.as_mut(),
1278 UserExerciseStateUpdate {
1279 id: user_exercise_state.id,
1280 score_given: Some(1.0),
1281 activity_progress: ActivityProgress::Completed,
1282 reviewing_stage: ReviewingStage::NotStarted,
1283 grading_progress: GradingProgress::FullyGraded,
1284 },
1285 )
1286 .await
1287 .unwrap();
1288
1289 move_chapter_exercises_to_manual_review(tx.as_mut(), chapter, user, course)
1290 .await
1291 .unwrap();
1292
1293 let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
1294 let user_exercise_state =
1295 user_exercise_states::get_users_current_by_exercise(tx.as_mut(), user, &exercise)
1296 .await
1297 .unwrap();
1298 assert_eq!(user_exercise_state.reviewing_stage, ReviewingStage::Locked);
1299 }
1300 }
1301
1302 mod constraints {
1303 use super::*;
1304 use crate::{courses::NewCourse, library, test_helper::*};
1305
1306 #[tokio::test]
1307 async fn cannot_create_chapter_for_different_course_than_its_module() {
1308 insert_data!(:tx, :user, :org, course: course_1, instance: _instance, :course_module);
1309 let course_2 = library::content_management::create_new_course(
1310 tx.as_mut(),
1311 PKeyPolicy::Generate,
1312 NewCourse {
1313 name: "".to_string(),
1314 slug: "course-2".to_string(),
1315 organization_id: org,
1316 language_code: "en".to_string(),
1317 teacher_in_charge_name: "Teacher".to_string(),
1318 teacher_in_charge_email: "teacher@example.com".to_string(),
1319 description: "".to_string(),
1320 is_draft: false,
1321 is_test_mode: false,
1322 is_unlisted: false,
1323 copy_user_permissions: false,
1324 is_joinable_by_code_only: false,
1325 join_code: None,
1326 ask_marketing_consent: false,
1327 flagged_answers_threshold: Some(3),
1328 can_add_chatbot: false,
1329 },
1330 user,
1331 |_, _, _| unimplemented!(),
1332 |_| unimplemented!(),
1333 )
1334 .await
1335 .unwrap()
1336 .0
1337 .id;
1338 let chapter_result_2 = insert(
1339 tx.as_mut(),
1340 PKeyPolicy::Generate,
1341 &NewChapter {
1342 name: "Chapter of second course".to_string(),
1343 color: None,
1344 course_id: course_2,
1345 chapter_number: 0,
1346 front_page_id: None,
1347 opens_at: None,
1348 deadline: None,
1349 course_module_id: Some(course_module.id),
1350 },
1351 )
1352 .await;
1353 assert!(
1354 chapter_result_2.is_err(),
1355 "Expected chapter creation to fail when course module belongs to a different course."
1356 );
1357 }
1358 }
1359}