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