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