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
326#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
327
328pub struct ChapterWithStatus {
329 pub id: Uuid,
330 pub created_at: DateTime<Utc>,
331 pub updated_at: DateTime<Utc>,
332 pub name: String,
333 pub color: Option<String>,
334 pub course_id: Uuid,
335 pub deleted_at: Option<DateTime<Utc>>,
336 pub chapter_number: i32,
337 pub front_page_id: Option<Uuid>,
338 pub opens_at: Option<DateTime<Utc>>,
339 pub deadline: Option<DateTime<Utc>>,
340 pub status: ChapterStatus,
341 pub chapter_image_url: Option<String>,
342 pub course_module_id: Uuid,
343 pub exercise_deadline_override_count: i64,
344 pub exercise_deadline_override_distinct_count: i64,
345 pub earliest_exercise_deadline_override: Option<DateTime<Utc>>,
346}
347
348#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, Default)]
349
350pub struct ChapterExerciseDeadlineOverrideSummary {
351 pub earliest_exercise_deadline_override: Option<DateTime<Utc>>,
352 pub exercise_deadline_override_count: i64,
353 pub exercise_deadline_override_distinct_count: i64,
354}
355
356impl ChapterWithStatus {
357 pub fn from_database_chapter_timestamp_and_image_url(
358 database_chapter: DatabaseChapter,
359 timestamp: DateTime<Utc>,
360 chapter_image_url: Option<String>,
361 exercise_deadline_overrides: Option<ChapterExerciseDeadlineOverrideSummary>,
362 ) -> Self {
363 let open = database_chapter
364 .opens_at
365 .map(|o| o <= timestamp)
366 .unwrap_or(true);
367 let status = if open {
368 ChapterStatus::Open
369 } else {
370 ChapterStatus::Closed
371 };
372 let exercise_deadline_overrides = exercise_deadline_overrides.unwrap_or_default();
373 ChapterWithStatus {
374 id: database_chapter.id,
375 created_at: database_chapter.created_at,
376 updated_at: database_chapter.updated_at,
377 name: database_chapter.name,
378 color: database_chapter.color,
379 course_id: database_chapter.course_id,
380 deleted_at: database_chapter.deleted_at,
381 chapter_number: database_chapter.chapter_number,
382 front_page_id: database_chapter.front_page_id,
383 opens_at: database_chapter.opens_at,
384 deadline: database_chapter.deadline,
385 status,
386 chapter_image_url,
387 course_module_id: database_chapter.course_module_id,
388 exercise_deadline_override_count: exercise_deadline_overrides
389 .exercise_deadline_override_count,
390 exercise_deadline_override_distinct_count: exercise_deadline_overrides
391 .exercise_deadline_override_distinct_count,
392 earliest_exercise_deadline_override: exercise_deadline_overrides
393 .earliest_exercise_deadline_override,
394 }
395 }
396}
397
398#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, ToSchema)]
399
400pub struct UserCourseInstanceChapterProgress {
401 pub score_given: f32,
402 pub score_maximum: i32,
403 pub total_exercises: Option<u32>,
404 pub attempted_exercises: Option<u32>,
405}
406
407pub async fn get_course_chapters(
408 conn: &mut PgConnection,
409 course_id: Uuid,
410) -> ModelResult<Vec<DatabaseChapter>> {
411 let chapters = sqlx::query_as!(
412 DatabaseChapter,
413 r#"
414SELECT *
415FROM chapters
416WHERE course_id = $1
417 AND deleted_at IS NULL;
418"#,
419 course_id
420 )
421 .fetch_all(conn)
422 .await?;
423 Ok(chapters)
424}
425
426pub async fn exercise_deadline_overrides_by_chapter_for_course(
427 conn: &mut PgConnection,
428 course_id: Uuid,
429) -> ModelResult<HashMap<Uuid, ChapterExerciseDeadlineOverrideSummary>> {
430 let rows = sqlx::query!(
431 r#"
432SELECT
433 e.chapter_id,
434 MIN(COALESCE(e.deadline, c.deadline)) FILTER (
435 WHERE COALESCE(e.deadline, c.deadline) IS NOT NULL
436 ) AS earliest_exercise_deadline_override,
437 COUNT(*) FILTER (
438 WHERE e.deadline IS NOT NULL
439 AND (c.deadline IS NULL OR e.deadline <> c.deadline)
440 ) AS exercise_deadline_override_count,
441 COUNT(DISTINCT COALESCE(e.deadline, c.deadline)) FILTER (
442 WHERE COALESCE(e.deadline, c.deadline) IS NOT NULL
443 ) AS exercise_deadline_override_distinct_count
444FROM exercises e
445JOIN chapters c ON c.id = e.chapter_id
446WHERE c.course_id = $1
447 AND c.deleted_at IS NULL
448 AND e.deleted_at IS NULL
449GROUP BY e.chapter_id, c.deadline
450 "#,
451 course_id
452 )
453 .fetch_all(conn)
454 .await?;
455
456 let mut summaries = HashMap::new();
457 for row in rows {
458 if let Some(chapter_id) = row.chapter_id {
459 summaries.insert(
460 chapter_id,
461 ChapterExerciseDeadlineOverrideSummary {
462 earliest_exercise_deadline_override: row.earliest_exercise_deadline_override,
463 exercise_deadline_override_count: row
464 .exercise_deadline_override_count
465 .unwrap_or(0),
466 exercise_deadline_override_distinct_count: row
467 .exercise_deadline_override_distinct_count
468 .unwrap_or(0),
469 },
470 );
471 }
472 }
473 Ok(summaries)
474}
475
476pub async fn course_instance_chapters(
477 conn: &mut PgConnection,
478 course_instance_id: Uuid,
479) -> ModelResult<Vec<DatabaseChapter>> {
480 let chapters = sqlx::query_as!(
481 DatabaseChapter,
482 r#"
483SELECT id,
484 created_at,
485 updated_at,
486 name,
487 color,
488 course_id,
489 deleted_at,
490 chapter_image_path,
491 chapter_number,
492 front_page_id,
493 opens_at,
494 copied_from,
495 deadline,
496 course_module_id
497FROM chapters
498WHERE course_id = (SELECT course_id FROM course_instances WHERE id = $1)
499 AND deleted_at IS NULL;
500"#,
501 course_instance_id
502 )
503 .fetch_all(conn)
504 .await?;
505 Ok(chapters)
506}
507
508pub async fn delete_chapter(
509 conn: &mut PgConnection,
510 chapter_id: Uuid,
511) -> ModelResult<DatabaseChapter> {
512 let mut tx = conn.begin().await?;
513 let deleted = sqlx::query_as!(
514 DatabaseChapter,
515 r#"
516UPDATE chapters
517SET deleted_at = now()
518WHERE id = $1
519AND deleted_at IS NULL
520RETURNING *;
521"#,
522 chapter_id
523 )
524 .fetch_one(&mut *tx)
525 .await?;
526 sqlx::query!(
528 "UPDATE pages SET deleted_at = now() WHERE chapter_id = $1 AND deleted_at IS NULL;",
529 chapter_id
530 )
531 .execute(&mut *tx)
532 .await?;
533 sqlx::query!(
534 "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));",
535 chapter_id
536 )
537 .execute(&mut *tx).await?;
538 sqlx::query!(
539 "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);",
540 chapter_id
541 )
542 .execute(&mut *tx).await?;
543 sqlx::query!(
544 "UPDATE exercises SET deleted_at = now() WHERE deleted_at IS NULL AND chapter_id = $1;",
545 chapter_id
546 )
547 .execute(&mut *tx)
548 .await?;
549 tx.commit().await?;
550 Ok(deleted)
551}
552
553pub async fn get_user_course_instance_chapter_progress(
554 conn: &mut PgConnection,
555 course_instance_id: Uuid,
556 chapter_id: Uuid,
557 user_id: Uuid,
558) -> ModelResult<UserCourseInstanceChapterProgress> {
559 let course_instance =
560 crate::course_instances::get_course_instance(conn, course_instance_id).await?;
561 let mut exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
562
563 let exercise_ids: Vec<Uuid> = exercises.iter_mut().map(|e| e.id).collect();
564 let score_maximum: i32 = exercises.into_iter().map(|e| e.score_maximum).sum();
565
566 let user_chapter_metrics = crate::user_exercise_states::get_user_course_chapter_metrics(
567 conn,
568 course_instance.course_id,
569 &exercise_ids,
570 user_id,
571 )
572 .await?;
573
574 let result = UserCourseInstanceChapterProgress {
575 score_given: option_f32_to_f32_two_decimals_with_none_as_zero(
576 user_chapter_metrics.score_given,
577 ),
578 score_maximum,
579 total_exercises: Some(TryInto::try_into(exercise_ids.len())).transpose()?,
580 attempted_exercises: user_chapter_metrics
581 .attempted_exercises
582 .map(TryInto::try_into)
583 .transpose()?,
584 };
585 Ok(result)
586}
587
588pub async fn get_chapter_by_page_id(
589 conn: &mut PgConnection,
590 page_id: Uuid,
591) -> ModelResult<DatabaseChapter> {
592 let chapter = sqlx::query_as!(
593 DatabaseChapter,
594 "
595SELECT c.*
596FROM chapters c,
597 pages p
598WHERE c.id = p.chapter_id
599 AND p.id = $1
600 AND c.deleted_at IS NULL
601 ",
602 page_id
603 )
604 .fetch_one(conn)
605 .await?;
606
607 Ok(chapter)
608}
609
610pub async fn get_chapter_info_by_page_metadata(
611 conn: &mut PgConnection,
612 current_page_metadata: &PageMetadata,
613) -> ModelResult<ChapterInfo> {
614 let chapter_page = sqlx::query_as!(
615 ChapterInfo,
616 "
617 SELECT
618 c.id as chapter_id,
619 c.name as chapter_name,
620 c.front_page_id as chapter_front_page_id
621 FROM chapters c
622 WHERE c.id = $1
623 AND c.course_id = $2
624 AND c.deleted_at IS NULL;
625 ",
626 current_page_metadata.chapter_id,
627 current_page_metadata.course_id
628 )
629 .fetch_one(conn)
630 .await?;
631
632 Ok(chapter_page)
633}
634
635pub async fn set_module(
636 conn: &mut PgConnection,
637 chapter_id: Uuid,
638 module_id: Uuid,
639) -> ModelResult<()> {
640 sqlx::query!(
641 "
642UPDATE chapters
643SET course_module_id = $2
644WHERE id = $1
645",
646 chapter_id,
647 module_id
648 )
649 .execute(conn)
650 .await?;
651 Ok(())
652}
653
654pub async fn get_for_module(conn: &mut PgConnection, module_id: Uuid) -> ModelResult<Vec<Uuid>> {
655 let res = sqlx::query!(
656 "
657SELECT id
658FROM chapters
659WHERE course_module_id = $1
660AND deleted_at IS NULL
661",
662 module_id
663 )
664 .map(|c| c.id)
665 .fetch_all(conn)
666 .await?;
667 Ok(res)
668}
669
670#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
671
672pub struct UserChapterProgress {
673 pub user_id: Uuid,
674 pub chapter_id: Uuid,
675 pub chapter_number: i32,
676 pub chapter_name: String,
677 pub points_obtained: f64,
678 pub exercises_attempted: i64,
679}
680
681#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
682
683pub struct ChapterAvailability {
684 pub chapter_id: Uuid,
685 pub chapter_number: i32,
686 pub chapter_name: String,
687 pub exercises_available: i64,
688 pub points_available: i64,
689}
690
691#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
692
693pub struct CourseUserInfo {
694 pub first_name: Option<String>,
695 pub last_name: Option<String>,
696 pub user_id: Uuid,
697 pub email: Option<String>,
698 pub course_instance: Option<String>,
699}
700
701pub async fn fetch_user_chapter_progress(
704 conn: &mut PgConnection,
705 course_id: Uuid,
706 user_ids: Option<&[Uuid]>,
707) -> ModelResult<Vec<UserChapterProgress>> {
708 let rows = sqlx::query_as!(
709 UserChapterProgress,
710 r#"
711WITH base AS (
712 SELECT ues.user_id,
713 ex.chapter_id,
714 ues.exercise_id,
715 COALESCE(ues.score_given, 0)::double precision AS points
716 FROM user_exercise_states ues
717 JOIN exercises ex ON ex.id = ues.exercise_id
718 WHERE ues.course_id = $1
719 AND ($2::uuid[] IS NULL OR ues.user_id = ANY($2::uuid[]))
720 AND ues.deleted_at IS NULL
721 AND ex.deleted_at IS NULL
722)
723SELECT b.user_id AS user_id,
724 c.id AS chapter_id,
725 c.chapter_number AS chapter_number,
726 c.name AS chapter_name,
727 COALESCE(SUM(b.points), 0)::double precision AS "points_obtained!",
728 COALESCE(COUNT(DISTINCT b.exercise_id), 0)::bigint AS "exercises_attempted!"
729FROM base b
730 JOIN chapters c ON c.id = b.chapter_id
731GROUP BY b.user_id,
732 c.id,
733 c.chapter_number,
734 c.name
735ORDER BY b.user_id,
736 c.chapter_number
737 "#,
738 course_id,
739 user_ids as Option<&[Uuid]>
740 )
741 .fetch_all(&mut *conn)
742 .await?;
743
744 Ok(rows)
745}
746
747pub async fn fetch_chapter_availability(
748 conn: &mut PgConnection,
749 course_id: Uuid,
750) -> ModelResult<Vec<ChapterAvailability>> {
751 let rows = sqlx::query_as!(
752 ChapterAvailability,
753 r#"
754SELECT c.id AS chapter_id,
755 c.chapter_number AS chapter_number,
756 c.name AS chapter_name,
757 COALESCE(COUNT(ex.id), 0)::bigint AS "exercises_available!",
758 COALESCE(COUNT(ex.id), 0)::bigint AS "points_available!"
759FROM chapters c
760 JOIN exercises ex ON ex.chapter_id = c.id
761WHERE c.course_id = $1
762 AND c.deleted_at IS NULL
763 AND ex.deleted_at IS NULL
764GROUP BY c.id,
765 c.chapter_number,
766 c.name
767ORDER BY c.chapter_number
768 "#,
769 course_id
770 )
771 .fetch_all(conn)
772 .await?;
773
774 Ok(rows)
775}
776
777pub async fn fetch_course_users(
778 conn: &mut PgConnection,
779 course_id: Uuid,
780) -> ModelResult<Vec<CourseUserInfo>> {
781 let rows_raw = sqlx::query!(
782 r#"
783 SELECT
784 ud.first_name,
785 ud.last_name,
786 u.id AS user_id,
787 ud.email AS "email?",
788 ci.name AS "course_instance?"
789 FROM course_instance_enrollments AS cie
790 JOIN users AS u ON u.id = cie.user_id
791 LEFT JOIN user_details AS ud ON ud.user_id = u.id
792 JOIN course_instances AS ci ON ci.id = cie.course_instance_id
793 WHERE cie.course_id = $1
794 AND cie.deleted_at IS NULL
795 ORDER BY 1, user_id
796 "#,
797 course_id
798 )
799 .fetch_all(conn)
800 .await?;
801
802 let rows = rows_raw
803 .into_iter()
804 .map(|r| {
805 let first_name = r
806 .first_name
807 .map(|f| f.trim().to_string())
808 .filter(|f| !f.is_empty());
809 let last_name = r
810 .last_name
811 .map(|l| l.trim().to_string())
812 .filter(|l| !l.is_empty());
813
814 CourseUserInfo {
815 first_name,
816 last_name,
817 user_id: r.user_id,
818 email: r.email,
819 course_instance: r.course_instance,
820 }
821 })
822 .collect();
823
824 Ok(rows)
825}
826
827#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
828
829pub struct UnreturnedExercise {
830 pub id: Uuid,
831 pub name: String,
832}
833
834#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
835
836pub struct ChapterLockPreview {
837 pub has_unreturned_exercises: bool,
838 pub unreturned_exercises_count: i32,
839 pub unreturned_exercises: Vec<UnreturnedExercise>,
840}
841
842pub async fn get_chapter_lock_preview(
843 conn: &mut PgConnection,
844 chapter_id: Uuid,
845 user_id: Uuid,
846 course_id: Uuid,
847) -> ModelResult<ChapterLockPreview> {
848 let exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
849
850 if exercises.is_empty() {
851 return Ok(ChapterLockPreview {
852 has_unreturned_exercises: false,
853 unreturned_exercises_count: 0,
854 unreturned_exercises: Vec::new(),
855 });
856 }
857
858 let exercise_ids: Vec<Uuid> = exercises.iter().map(|e| e.id).collect();
859
860 let returned_exercise_ids =
861 crate::user_exercise_states::get_returned_exercise_ids_for_user_and_course(
862 conn,
863 &exercise_ids,
864 user_id,
865 course_id,
866 )
867 .await?;
868
869 let returned_ids: std::collections::HashSet<Uuid> = returned_exercise_ids.into_iter().collect();
870
871 let unreturned_exercises: Vec<UnreturnedExercise> = exercises
872 .into_iter()
873 .filter(|e| !returned_ids.contains(&e.id))
874 .map(|e| UnreturnedExercise {
875 id: e.id,
876 name: e.name,
877 })
878 .collect();
879
880 let count = unreturned_exercises.len() as i32;
881 let has_unreturned = count > 0;
882
883 Ok(ChapterLockPreview {
884 has_unreturned_exercises: has_unreturned,
885 unreturned_exercises_count: count,
886 unreturned_exercises,
887 })
888}
889
890pub async fn get_previous_chapters_in_module(
891 conn: &mut PgConnection,
892 chapter_id: Uuid,
893) -> ModelResult<Vec<DatabaseChapter>> {
894 let chapter = get_chapter(conn, chapter_id).await?;
895 let previous_chapters = sqlx::query_as!(
896 DatabaseChapter,
897 r#"
898SELECT *
899FROM chapters
900WHERE course_module_id = $1
901 AND chapter_number < $2
902 AND deleted_at IS NULL
903ORDER BY chapter_number ASC
904 "#,
905 chapter.course_module_id,
906 chapter.chapter_number
907 )
908 .fetch_all(conn)
909 .await?;
910 Ok(previous_chapters)
911}
912
913pub async fn move_chapter_exercises_to_manual_review(
914 conn: &mut PgConnection,
915 chapter_id: Uuid,
916 user_id: Uuid,
917 course_id: Uuid,
918) -> ModelResult<()> {
919 let exercises = exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
920
921 let exercise_ids: Vec<Uuid> = exercises.iter().map(|e| e.id).collect();
924 let answered_ids: std::collections::HashSet<Uuid> =
925 exercise_slide_submissions::get_exercise_ids_with_submissions_for_user(
926 conn,
927 &exercise_ids,
928 user_id,
929 course_id,
930 )
931 .await?
932 .into_iter()
933 .collect();
934
935 for exercise in exercises {
936 let user_exercise_state_result =
937 user_exercise_states::get_users_current_by_exercise(conn, user_id, &exercise).await;
938
939 let user_exercise_state = match user_exercise_state_result {
940 Ok(state) => state,
941 Err(e) => {
942 if matches!(
943 e.error_type(),
944 ModelErrorType::PreconditionFailed | ModelErrorType::RecordNotFound
945 ) {
946 continue;
947 }
948 return Err(e);
949 }
950 };
951 if user_exercise_state.reviewing_stage == ReviewingStage::WaitingForManualGrading
952 || user_exercise_state.reviewing_stage == ReviewingStage::ReviewedAndLocked
953 || user_exercise_state.reviewing_stage == ReviewingStage::Locked
954 || user_exercise_state.selected_exercise_slide_id.is_none()
955 {
956 continue;
957 }
958
959 if !answered_ids.contains(&exercise.id) {
960 user_exercise_states::update_reviewing_stage(
961 conn,
962 user_id,
963 CourseOrExamId::Course(course_id),
964 exercise.id,
965 ReviewingStage::NotAnsweredAndLocked,
966 )
967 .await?;
968 continue;
969 }
970
971 if exercise.needs_peer_review || exercise.needs_self_review {
972 user_exercise_states::update_reviewing_stage(
973 conn,
974 user_id,
975 CourseOrExamId::Course(course_id),
976 exercise.id,
977 ReviewingStage::WaitingForManualGrading,
978 )
979 .await?;
980 continue;
981 }
982
983 if !exercise.teacher_reviews_answer_after_locking
984 && user_exercise_state.grading_progress == GradingProgress::FullyGraded
985 {
986 user_exercise_states::update_reviewing_stage(
987 conn,
988 user_id,
989 CourseOrExamId::Course(course_id),
990 exercise.id,
991 ReviewingStage::Locked,
992 )
993 .await?;
994 user_exercise_state_updater::update_user_exercise_state(conn, user_exercise_state.id)
995 .await?;
996 continue;
997 }
998
999 user_exercise_states::update_reviewing_stage(
1000 conn,
1001 user_id,
1002 CourseOrExamId::Course(course_id),
1003 exercise.id,
1004 ReviewingStage::WaitingForManualGrading,
1005 )
1006 .await?;
1007 }
1008
1009 Ok(())
1010}
1011
1012pub async fn unlock_first_chapters_for_user(
1015 conn: &mut PgConnection,
1016 user_id: Uuid,
1017 course_id: Uuid,
1018) -> ModelResult<Vec<Uuid>> {
1019 use crate::{course_modules, exercises, user_chapter_locking_statuses};
1020
1021 let all_modules = course_modules::get_by_course_id(conn, course_id).await?;
1022 let base_module = all_modules
1023 .into_iter()
1024 .find(|m| m.order_number == 0)
1025 .ok_or_else(|| {
1026 ModelError::new(
1027 ModelErrorType::NotFound,
1028 "Base module not found".to_string(),
1029 None,
1030 )
1031 })?;
1032
1033 let module_chapter_ids = get_for_module(conn, base_module.id).await?;
1034 let mut module_chapters = get_course_chapters(conn, course_id)
1035 .await?
1036 .into_iter()
1037 .filter(|c| module_chapter_ids.contains(&c.id))
1038 .collect::<Vec<_>>();
1039 module_chapters.sort_by_key(|c| c.chapter_number);
1040
1041 let mut chapters_to_unlock = Vec::new();
1042
1043 for chapter in &module_chapters {
1044 let exercises = exercises::get_exercises_by_chapter_id(conn, chapter.id).await?;
1045 let has_exercises = !exercises.is_empty();
1046
1047 if has_exercises {
1048 chapters_to_unlock.push(chapter.id);
1049 break;
1050 } else {
1051 chapters_to_unlock.push(chapter.id);
1052 }
1053 }
1054
1055 for chapter_id in &chapters_to_unlock {
1056 user_chapter_locking_statuses::unlock_chapter(conn, user_id, *chapter_id, course_id)
1057 .await?;
1058 }
1059
1060 Ok(chapters_to_unlock)
1061}
1062
1063pub async fn unlock_next_chapters_for_user(
1069 conn: &mut PgConnection,
1070 user_id: Uuid,
1071 chapter_id: Uuid,
1072 course_id: Uuid,
1073) -> ModelResult<Vec<Uuid>> {
1074 use crate::{course_modules, exercises, user_chapter_locking_statuses};
1075
1076 let completed_chapter = get_chapter(conn, chapter_id).await?;
1077 let module = course_modules::get_by_id(conn, completed_chapter.course_module_id).await?;
1078
1079 let module_chapters = get_for_module(conn, completed_chapter.course_module_id).await?;
1080 let mut all_module_chapters = get_course_chapters(conn, course_id)
1081 .await?
1082 .into_iter()
1083 .filter(|c| module_chapters.contains(&c.id))
1084 .collect::<Vec<_>>();
1085 all_module_chapters.sort_by_key(|c| c.chapter_number);
1086
1087 let mut chapters_to_unlock = Vec::new();
1088
1089 let is_base_module = module.order_number == 0;
1090
1091 let course = courses::get_course(conn, course_id).await?;
1092 let mut all_module_chapters_completed = true;
1093 for chapter in &all_module_chapters {
1094 let status = user_chapter_locking_statuses::get_or_init_status(
1095 conn,
1096 user_id,
1097 chapter.id,
1098 Some(course_id),
1099 Some(course.chapter_locking_enabled),
1100 )
1101 .await?;
1102 if !matches!(
1103 status,
1104 Some(user_chapter_locking_statuses::ChapterLockingStatus::CompletedAndLocked)
1105 ) {
1106 all_module_chapters_completed = false;
1107 break;
1108 }
1109 }
1110
1111 if is_base_module && all_module_chapters_completed {
1112 let all_modules = course_modules::get_by_course_id(conn, course_id).await?;
1113 let additional_modules: Vec<_> = all_modules
1114 .into_iter()
1115 .filter(|m| m.order_number != 0)
1116 .collect();
1117
1118 let mut all_additional_module_chapter_ids = Vec::new();
1119 for additional_module in &additional_modules {
1120 let module_chapter_ids = get_for_module(conn, additional_module.id).await?;
1121 all_additional_module_chapter_ids.extend(module_chapter_ids);
1122 }
1123
1124 let all_exercises = if !all_additional_module_chapter_ids.is_empty() {
1125 exercises::get_exercises_by_chapter_ids(conn, &all_additional_module_chapter_ids)
1126 .await?
1127 } else {
1128 Vec::new()
1129 };
1130
1131 let exercises_by_chapter: std::collections::HashMap<Uuid, Vec<_>> = all_exercises
1132 .into_iter()
1133 .fold(std::collections::HashMap::new(), |mut acc, ex| {
1134 if let Some(ch_id) = ex.chapter_id {
1135 acc.entry(ch_id).or_insert_with(Vec::new).push(ex);
1136 }
1137 acc
1138 });
1139
1140 for additional_module in additional_modules {
1141 let module_chapter_ids = get_for_module(conn, additional_module.id).await?;
1142 let mut module_chapters = get_course_chapters(conn, course_id)
1143 .await?
1144 .into_iter()
1145 .filter(|c| module_chapter_ids.contains(&c.id))
1146 .collect::<Vec<_>>();
1147 module_chapters.sort_by_key(|c| c.chapter_number);
1148
1149 for chapter in &module_chapters {
1150 let has_exercises = exercises_by_chapter
1151 .get(&chapter.id)
1152 .map(|exs| !exs.is_empty())
1153 .unwrap_or(false);
1154
1155 if has_exercises {
1156 chapters_to_unlock.push(chapter.id);
1157 break;
1158 } else {
1159 chapters_to_unlock.push(chapter.id);
1160 }
1161 }
1162 }
1163 } else {
1164 let module_chapter_ids = get_for_module(conn, completed_chapter.course_module_id).await?;
1165 let mut module_chapters = get_course_chapters(conn, course_id)
1166 .await?
1167 .into_iter()
1168 .filter(|c| module_chapter_ids.contains(&c.id))
1169 .collect::<Vec<_>>();
1170 module_chapters.sort_by_key(|c| c.chapter_number);
1171 let mut found_completed = false;
1172 let mut candidate_chapter_ids = Vec::new();
1173
1174 for chapter in &module_chapters {
1175 if chapter.id == completed_chapter.id {
1176 found_completed = true;
1177 continue;
1178 }
1179
1180 if !found_completed {
1181 continue;
1182 }
1183
1184 candidate_chapter_ids.push(chapter.id);
1185 }
1186
1187 let all_exercises = if !candidate_chapter_ids.is_empty() {
1188 exercises::get_exercises_by_chapter_ids(conn, &candidate_chapter_ids).await?
1189 } else {
1190 Vec::new()
1191 };
1192
1193 let exercises_by_chapter: std::collections::HashMap<Uuid, Vec<_>> = all_exercises
1194 .into_iter()
1195 .fold(std::collections::HashMap::new(), |mut acc, ex| {
1196 if let Some(ch_id) = ex.chapter_id {
1197 acc.entry(ch_id).or_insert_with(Vec::new).push(ex);
1198 }
1199 acc
1200 });
1201
1202 for chapter_id in candidate_chapter_ids {
1203 let has_exercises = exercises_by_chapter
1204 .get(&chapter_id)
1205 .map(|exs| !exs.is_empty())
1206 .unwrap_or(false);
1207
1208 if has_exercises {
1209 chapters_to_unlock.push(chapter_id);
1210 break;
1211 } else {
1212 chapters_to_unlock.push(chapter_id);
1213 }
1214 }
1215 }
1216
1217 for chapter_id in &chapters_to_unlock {
1218 user_chapter_locking_statuses::unlock_chapter(conn, user_id, *chapter_id, course_id)
1219 .await?;
1220 }
1221
1222 Ok(chapters_to_unlock)
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::*;
1228
1229 mod move_chapter_exercises_to_manual_review {
1230 use super::*;
1231 use crate::{
1232 exercises::ActivityProgress,
1233 test_helper::*,
1234 user_exercise_slide_states,
1235 user_exercise_states::{self, UserExerciseStateUpdate},
1236 };
1237
1238 #[tokio::test]
1239 async fn fully_graded_auto_review_exercise_becomes_locked() {
1240 insert_data!(
1241 :tx,
1242 :user,
1243 :org,
1244 :course,
1245 instance: _instance,
1246 :course_module,
1247 :chapter,
1248 :page,
1249 :exercise,
1250 :slide
1251 );
1252
1253 exercises::update_teacher_reviews_answer_after_locking(tx.as_mut(), exercise, false)
1254 .await
1255 .unwrap();
1256
1257 user_exercise_states::upsert_selected_exercise_slide_id(
1258 tx.as_mut(),
1259 user,
1260 exercise,
1261 Some(course),
1262 None,
1263 Some(slide),
1264 )
1265 .await
1266 .unwrap();
1267
1268 let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
1269 tx.as_mut(),
1270 user,
1271 exercise,
1272 Some(course),
1273 None,
1274 )
1275 .await
1276 .unwrap();
1277
1278 exercise_slide_submissions::insert_exercise_slide_submission(
1279 tx.as_mut(),
1280 exercise_slide_submissions::NewExerciseSlideSubmission {
1281 exercise_slide_id: slide,
1282 course_id: Some(course),
1283 exam_id: None,
1284 user_id: user,
1285 exercise_id: exercise,
1286 user_points_update_strategy:
1287 crate::exercise_task_gradings::UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
1288 },
1289 )
1290 .await
1291 .unwrap();
1292
1293 let user_exercise_slide_state =
1294 user_exercise_slide_states::get_or_insert_by_unique_index(
1295 tx.as_mut(),
1296 user_exercise_state.id,
1297 slide,
1298 )
1299 .await
1300 .unwrap();
1301 user_exercise_slide_states::update(
1302 tx.as_mut(),
1303 user_exercise_slide_state.id,
1304 Some(1.0),
1305 GradingProgress::FullyGraded,
1306 )
1307 .await
1308 .unwrap();
1309
1310 user_exercise_states::update(
1311 tx.as_mut(),
1312 UserExerciseStateUpdate {
1313 id: user_exercise_state.id,
1314 score_given: Some(1.0),
1315 activity_progress: ActivityProgress::Completed,
1316 reviewing_stage: ReviewingStage::NotStarted,
1317 grading_progress: GradingProgress::FullyGraded,
1318 },
1319 )
1320 .await
1321 .unwrap();
1322
1323 move_chapter_exercises_to_manual_review(tx.as_mut(), chapter, user, course)
1324 .await
1325 .unwrap();
1326
1327 let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
1328 let user_exercise_state =
1329 user_exercise_states::get_users_current_by_exercise(tx.as_mut(), user, &exercise)
1330 .await
1331 .unwrap();
1332 assert_eq!(user_exercise_state.reviewing_stage, ReviewingStage::Locked);
1333 }
1334
1335 #[tokio::test]
1336 async fn never_returned_exercise_becomes_not_answered_and_locked() {
1337 insert_data!(
1338 :tx,
1339 :user,
1340 :org,
1341 :course,
1342 instance: _instance,
1343 :course_module,
1344 :chapter,
1345 :page,
1346 :exercise,
1347 :slide
1348 );
1349
1350 user_exercise_states::upsert_selected_exercise_slide_id(
1352 tx.as_mut(),
1353 user,
1354 exercise,
1355 Some(course),
1356 None,
1357 Some(slide),
1358 )
1359 .await
1360 .unwrap();
1361
1362 user_exercise_states::get_or_create_user_exercise_state(
1363 tx.as_mut(),
1364 user,
1365 exercise,
1366 Some(course),
1367 None,
1368 )
1369 .await
1370 .unwrap();
1371
1372 move_chapter_exercises_to_manual_review(tx.as_mut(), chapter, user, course)
1373 .await
1374 .unwrap();
1375
1376 let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
1377 let user_exercise_state =
1378 user_exercise_states::get_users_current_by_exercise(tx.as_mut(), user, &exercise)
1379 .await
1380 .unwrap();
1381 assert_eq!(
1382 user_exercise_state.reviewing_stage,
1383 ReviewingStage::NotAnsweredAndLocked
1384 );
1385 }
1386
1387 #[tokio::test]
1388 async fn submitted_peer_review_exercise_still_goes_to_manual_grading() {
1389 insert_data!(
1390 :tx,
1391 :user,
1392 :org,
1393 :course,
1394 instance: _instance,
1395 :course_module,
1396 :chapter,
1397 :page,
1398 :exercise,
1399 :slide
1400 );
1401
1402 exercises::set_exercise_to_use_exercise_specific_peer_or_self_review_config(
1403 tx.as_mut(),
1404 exercise,
1405 true,
1406 false,
1407 false,
1408 )
1409 .await
1410 .unwrap();
1411
1412 user_exercise_states::upsert_selected_exercise_slide_id(
1413 tx.as_mut(),
1414 user,
1415 exercise,
1416 Some(course),
1417 None,
1418 Some(slide),
1419 )
1420 .await
1421 .unwrap();
1422
1423 user_exercise_states::get_or_create_user_exercise_state(
1424 tx.as_mut(),
1425 user,
1426 exercise,
1427 Some(course),
1428 None,
1429 )
1430 .await
1431 .unwrap();
1432
1433 exercise_slide_submissions::insert_exercise_slide_submission(
1436 tx.as_mut(),
1437 exercise_slide_submissions::NewExerciseSlideSubmission {
1438 exercise_slide_id: slide,
1439 course_id: Some(course),
1440 exam_id: None,
1441 user_id: user,
1442 exercise_id: exercise,
1443 user_points_update_strategy:
1444 crate::exercise_task_gradings::UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
1445 },
1446 )
1447 .await
1448 .unwrap();
1449
1450 move_chapter_exercises_to_manual_review(tx.as_mut(), chapter, user, course)
1451 .await
1452 .unwrap();
1453
1454 let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
1455 let user_exercise_state =
1456 user_exercise_states::get_users_current_by_exercise(tx.as_mut(), user, &exercise)
1457 .await
1458 .unwrap();
1459 assert_eq!(
1460 user_exercise_state.reviewing_stage,
1461 ReviewingStage::WaitingForManualGrading
1462 );
1463 }
1464 }
1465
1466 mod constraints {
1467 use super::*;
1468 use crate::{courses::NewCourse, library, test_helper::*};
1469
1470 #[tokio::test]
1471 async fn cannot_create_chapter_for_different_course_than_its_module() {
1472 insert_data!(:tx, :user, :org, course: course_1, instance: _instance, :course_module);
1473 let course_2 = library::content_management::create_new_course(
1474 tx.as_mut(),
1475 PKeyPolicy::Generate,
1476 NewCourse {
1477 name: "".to_string(),
1478 slug: "course-2".to_string(),
1479 organization_id: org,
1480 language_code: "en".to_string(),
1481 teacher_in_charge_name: "Teacher".to_string(),
1482 teacher_in_charge_email: "teacher@example.com".to_string(),
1483 description: "".to_string(),
1484 is_draft: false,
1485 is_test_mode: false,
1486 is_unlisted: false,
1487 copy_user_permissions: false,
1488 is_joinable_by_code_only: false,
1489 join_code: None,
1490 ask_marketing_consent: false,
1491 flagged_answers_threshold: Some(3),
1492 can_add_chatbot: false,
1493 },
1494 user,
1495 |_, _, _| unimplemented!(),
1496 |_| unimplemented!(),
1497 )
1498 .await
1499 .unwrap()
1500 .0
1501 .id;
1502 let chapter_result_2 = insert(
1503 tx.as_mut(),
1504 PKeyPolicy::Generate,
1505 &NewChapter {
1506 name: "Chapter of second course".to_string(),
1507 color: None,
1508 course_id: course_2,
1509 chapter_number: 0,
1510 front_page_id: None,
1511 opens_at: None,
1512 deadline: None,
1513 course_module_id: Some(course_module.id),
1514 },
1515 )
1516 .await;
1517 assert!(
1518 chapter_result_2.is_err(),
1519 "Expected chapter creation to fail when course module belongs to a different course."
1520 );
1521 }
1522 }
1523}