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