1use crate::error::missing_model_error;
2use crate::prelude::*;
3use utoipa::ToSchema;
4
5#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, ToSchema, sqlx::Type)]
6#[serde(rename_all = "snake_case")]
7#[sqlx(type_name = "chapter_locking_status", rename_all = "snake_case")]
8pub enum ChapterLockingStatus {
9 Unlocked,
11 CompletedAndLocked,
13 NotUnlockedYet,
15}
16
17#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema, sqlx::FromRow)]
18
19pub struct UserChapterLockingStatus {
20 pub id: Uuid,
21 pub created_at: DateTime<Utc>,
22 pub updated_at: DateTime<Utc>,
23 pub deleted_at: Option<DateTime<Utc>>,
24 pub user_id: Uuid,
25 pub chapter_id: Uuid,
26 pub course_id: Uuid,
27 pub status: ChapterLockingStatus,
28}
29
30async fn get_or_init_status_row(
31 conn: &mut PgConnection,
32 user_id: Uuid,
33 chapter_id: Uuid,
34 course_id: Option<Uuid>,
35 course_locking_enabled: Option<bool>,
36) -> ModelResult<Option<UserChapterLockingStatus>> {
37 let res = sqlx::query_as!(
38 UserChapterLockingStatus,
39 r#"
40SELECT *
41FROM user_chapter_locking_statuses
42WHERE user_id = $1
43 AND chapter_id = $2
44 AND deleted_at IS NULL
45 "#,
46 user_id,
47 chapter_id
48 )
49 .fetch_optional(&mut *conn)
50 .await?;
51
52 if let Some(row) = res {
53 return Ok(Some(row));
54 }
55
56 if let (Some(course_id), Some(true)) = (course_id, course_locking_enabled) {
57 return Ok(Some(
58 ensure_not_unlocked_yet_status(&mut *conn, user_id, chapter_id, course_id).await?,
59 ));
60 }
61
62 Ok(None)
63}
64
65pub async fn get_or_init_status(
66 conn: &mut PgConnection,
67 user_id: Uuid,
68 chapter_id: Uuid,
69 course_id: Option<Uuid>,
70 course_locking_enabled: Option<bool>,
71) -> ModelResult<Option<ChapterLockingStatus>> {
72 Ok(
73 get_or_init_status_row(conn, user_id, chapter_id, course_id, course_locking_enabled)
74 .await?
75 .map(|s| s.status),
76 )
77}
78
79pub async fn is_chapter_accessible(
80 conn: &mut PgConnection,
81 user_id: Uuid,
82 chapter_id: Uuid,
83 course_id: Uuid,
84) -> ModelResult<bool> {
85 use crate::courses;
86
87 let course = courses::get_course(conn, course_id).await?;
88
89 if !course.chapter_locking_enabled {
90 return Ok(true);
91 }
92
93 let status = get_or_init_status(
94 conn,
95 user_id,
96 chapter_id,
97 Some(course_id),
98 Some(course.chapter_locking_enabled),
99 )
100 .await?;
101 match status {
102 None => Ok(false),
103 Some(ChapterLockingStatus::Unlocked) => Ok(true),
104 Some(ChapterLockingStatus::CompletedAndLocked) => Ok(true),
105 Some(ChapterLockingStatus::NotUnlockedYet) => Ok(false),
106 }
107}
108
109pub async fn is_chapter_exercises_locked(
110 conn: &mut PgConnection,
111 user_id: Uuid,
112 chapter_id: Uuid,
113 course_id: Uuid,
114) -> ModelResult<bool> {
115 use crate::courses;
116
117 let course = courses::get_course(conn, course_id).await?;
118
119 if !course.chapter_locking_enabled {
120 return Ok(false);
121 }
122
123 let status = get_or_init_status(
124 conn,
125 user_id,
126 chapter_id,
127 Some(course_id),
128 Some(course.chapter_locking_enabled),
129 )
130 .await?;
131
132 match status {
133 None => Ok(true),
134 Some(ChapterLockingStatus::Unlocked) => Ok(false),
135 Some(ChapterLockingStatus::CompletedAndLocked) => Ok(true),
136 Some(ChapterLockingStatus::NotUnlockedYet) => Ok(true),
137 }
138}
139
140pub async fn unlock_chapter(
141 conn: &mut PgConnection,
142 user_id: Uuid,
143 chapter_id: Uuid,
144 course_id: Uuid,
145) -> ModelResult<UserChapterLockingStatus> {
146 let res = sqlx::query_as!(
147 UserChapterLockingStatus,
148 r#"
149INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
150VALUES ($1, $2, $3, 'unlocked'::chapter_locking_status, NULL)
151ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO UPDATE
152SET status = 'unlocked'::chapter_locking_status, deleted_at = NULL
153RETURNING *
154 "#,
155 user_id,
156 chapter_id,
157 course_id
158 )
159 .fetch_optional(&mut *conn)
160 .await?;
161
162 res.ok_or_else(missing_model_error(
163 ModelErrorType::NotFound,
164 "Failed to unlock chapter",
165 ))
166}
167
168pub async fn complete_and_lock_chapter(
169 conn: &mut PgConnection,
170 user_id: Uuid,
171 chapter_id: Uuid,
172 course_id: Uuid,
173) -> ModelResult<UserChapterLockingStatus> {
174 let res = sqlx::query_as!(
175 UserChapterLockingStatus,
176 r#"
177INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
178VALUES ($1, $2, $3, 'completed_and_locked'::chapter_locking_status, NULL)
179ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO UPDATE
180SET status = 'completed_and_locked'::chapter_locking_status, deleted_at = NULL
181RETURNING *
182 "#,
183 user_id,
184 chapter_id,
185 course_id
186 )
187 .fetch_optional(&mut *conn)
188 .await?;
189
190 res.ok_or_else(missing_model_error(
191 ModelErrorType::NotFound,
192 "Failed to complete chapter",
193 ))
194}
195
196pub async fn set_chapter_status(
197 conn: &mut PgConnection,
198 user_id: Uuid,
199 chapter_id: Uuid,
200 course_id: Uuid,
201 status: ChapterLockingStatus,
202) -> ModelResult<UserChapterLockingStatus> {
203 let res = sqlx::query_as!(
204 UserChapterLockingStatus,
205 r#"
206INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
207VALUES ($1, $2, $3, $4, NULL)
208ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO UPDATE
209SET status = $4, deleted_at = NULL
210RETURNING *
211 "#,
212 user_id,
213 chapter_id,
214 course_id,
215 status as ChapterLockingStatus,
216 )
217 .fetch_optional(&mut *conn)
218 .await?;
219
220 res.ok_or_else(missing_model_error(
221 ModelErrorType::NotFound,
222 "Failed to set chapter status",
223 ))
224}
225
226pub async fn get_or_init_all_for_course(
227 conn: &mut PgConnection,
228 user_id: Uuid,
229 course_id: Uuid,
230) -> ModelResult<Vec<UserChapterLockingStatus>> {
231 let course = crate::courses::get_course(conn, course_id).await?;
232 if course.chapter_locking_enabled {
233 init_for_users(conn, course_id, &[user_id]).await?;
234 }
235 let statuses = sqlx::query_as!(
236 UserChapterLockingStatus,
237 r#"
238SELECT *
239FROM user_chapter_locking_statuses
240WHERE user_id = $1
241 AND course_id = $2
242 AND deleted_at IS NULL
243 "#,
244 user_id,
245 course_id
246 )
247 .fetch_all(&mut *conn)
248 .await?;
249 Ok(statuses)
250}
251
252pub async fn init_for_users(
256 conn: &mut PgConnection,
257 course_id: Uuid,
258 user_ids: &[Uuid],
259) -> ModelResult<()> {
260 sqlx::query!(
261 r#"
262INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
263SELECT u.user_id,
264 chapters.id,
265 $2,
266 'not_unlocked_yet'::chapter_locking_status,
267 NULL
268FROM UNNEST($1::uuid []) AS u(user_id)
269 CROSS JOIN chapters
270WHERE chapters.course_id = $2
271 AND chapters.deleted_at IS NULL
272 AND NOT EXISTS (
273 SELECT 1
274 FROM user_chapter_locking_statuses s
275 WHERE s.user_id = u.user_id
276 AND s.chapter_id = chapters.id
277 AND s.deleted_at IS NULL
278 )
279ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
280 "#,
281 user_ids,
282 course_id
283 )
284 .execute(&mut *conn)
285 .await?;
286 let starting_users = sqlx::query_scalar!(
287 r#"
288SELECT user_id
289FROM user_chapter_locking_statuses
290WHERE course_id = $1
291 AND user_id = ANY($2)
292 AND deleted_at IS NULL
293GROUP BY user_id
294HAVING bool_and(status = 'not_unlocked_yet')
295 "#,
296 course_id,
297 user_ids
298 )
299 .fetch_all(&mut *conn)
300 .await?;
301 if starting_users.is_empty() {
302 return Ok(());
303 }
304 let chapters = crate::chapters::first_chapters_to_unlock(conn, course_id).await?;
305 sqlx::query!(
306 r#"
307INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
308SELECT u.user_id,
309 ch.chapter_id,
310 $3,
311 'unlocked'::chapter_locking_status,
312 NULL::timestamptz
313FROM UNNEST($1::uuid []) AS u(user_id)
314 CROSS JOIN UNNEST($2::uuid []) AS ch(chapter_id)
315ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO
316UPDATE
317SET status = 'unlocked'::chapter_locking_status,
318 deleted_at = NULL
319 "#,
320 &starting_users,
321 &chapters,
322 course_id
323 )
324 .execute(conn)
325 .await?;
326 Ok(())
327}
328
329pub async fn get_all_for_course(
330 conn: &mut PgConnection,
331 course: &crate::courses::Course,
332) -> ModelResult<Vec<UserChapterLockingStatus>> {
333 if !course.chapter_locking_enabled {
334 return Ok(Vec::new());
335 }
336
337 let rows = sqlx::query_as!(
338 UserChapterLockingStatus,
339 r#"
340SELECT *
341FROM user_chapter_locking_statuses
342WHERE course_id = $1
343 AND deleted_at IS NULL
344 "#,
345 course.id
346 )
347 .fetch_all(&mut *conn)
348 .await?;
349
350 Ok(rows)
351}
352
353pub async fn get_for_users_and_course(
355 conn: &mut PgConnection,
356 user_ids: &[Uuid],
357 course: &crate::courses::Course,
358) -> ModelResult<Vec<UserChapterLockingStatus>> {
359 if !course.chapter_locking_enabled {
360 return Ok(Vec::new());
361 }
362
363 let rows = sqlx::query_as!(
364 UserChapterLockingStatus,
365 r#"
366SELECT *
367FROM user_chapter_locking_statuses
368WHERE course_id = $1
369 AND user_id = ANY($2::uuid[])
370 AND deleted_at IS NULL
371 "#,
372 course.id,
373 user_ids
374 )
375 .fetch_all(&mut *conn)
376 .await?;
377
378 Ok(rows)
379}
380
381pub async fn get_for_user_and_course(
383 conn: &mut PgConnection,
384 user_id: Uuid,
385 course: &crate::courses::Course,
386) -> ModelResult<Vec<UserChapterLockingStatus>> {
387 if !course.chapter_locking_enabled {
388 return Ok(Vec::new());
389 }
390
391 let rows = sqlx::query_as!(
392 UserChapterLockingStatus,
393 r#"
394SELECT *
395FROM user_chapter_locking_statuses
396WHERE user_id = $1
397 AND course_id = $2
398 AND deleted_at IS NULL
399 "#,
400 user_id,
401 course.id
402 )
403 .fetch_all(&mut *conn)
404 .await?;
405
406 Ok(rows)
407}
408
409pub async fn ensure_not_unlocked_yet_status(
413 conn: &mut PgConnection,
414 user_id: Uuid,
415 chapter_id: Uuid,
416 course_id: Uuid,
417) -> ModelResult<UserChapterLockingStatus> {
418 let res: Option<UserChapterLockingStatus> = sqlx::query_as!(
419 UserChapterLockingStatus,
420 r#"
421INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
422VALUES ($1, $2, $3, 'not_unlocked_yet'::chapter_locking_status, NULL)
423ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
424RETURNING *
425 "#,
426 user_id,
427 chapter_id,
428 course_id
429 )
430 .fetch_optional(&mut *conn)
431 .await?;
432
433 if let Some(status) = res {
434 return Ok(status);
435 }
436
437 let retrieved = sqlx::query_as!(
438 UserChapterLockingStatus,
439 r#"
440SELECT *
441FROM user_chapter_locking_statuses
442WHERE user_id = $1
443 AND chapter_id = $2
444 AND deleted_at IS NULL
445 "#,
446 user_id,
447 chapter_id
448 )
449 .fetch_optional(&mut *conn)
450 .await?;
451
452 retrieved.ok_or_else(missing_model_error(
453 ModelErrorType::NotFound,
454 "Failed to ensure not_unlocked_yet status",
455 ))
456}
457
458pub async fn unlock_chapters_for_user(
460 conn: &mut PgConnection,
461 user_id: Uuid,
462 course_id: Uuid,
463 chapter_ids: &[Uuid],
464) -> ModelResult<()> {
465 if chapter_ids.is_empty() {
466 return Ok(());
467 }
468
469 let course = crate::courses::get_course(conn, course_id).await?;
470 if !course.chapter_locking_enabled {
471 sqlx::query!(
472 r#"
473UPDATE user_chapter_locking_statuses
474SET deleted_at = NOW()
475WHERE user_id = $1
476 AND course_id = $2
477 AND chapter_id = ANY($3)
478 AND deleted_at IS NULL
479 "#,
480 user_id,
481 course_id,
482 chapter_ids
483 )
484 .execute(&mut *conn)
485 .await?;
486
487 return Ok(());
488 }
489
490 sqlx::query!(
491 r#"
492UPDATE user_chapter_locking_statuses
493SET status = 'unlocked'::chapter_locking_status, deleted_at = NULL
494WHERE user_id = $1
495 AND course_id = $2
496 AND chapter_id = ANY($3)
497 AND status = 'completed_and_locked'::chapter_locking_status
498 AND deleted_at IS NULL
499 "#,
500 user_id,
501 course_id,
502 chapter_ids
503 )
504 .execute(&mut *conn)
505 .await?;
506
507 Ok(())
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use crate::test_helper::*;
514
515 async fn set_course_chapter_locking_enabled(
517 tx: &mut PgConnection,
518 course_id: Uuid,
519 chapter_locking_enabled: bool,
520 ) {
521 let app_config = init_app_conf().expect("Application Configuration initialization failed");
522 let course = crate::courses::get_course(tx, course_id).await.unwrap();
523 let live = crate::library::entity_snapshots::course_settings_of(tx, &course)
524 .await
525 .unwrap();
526 crate::courses::write_settings(
527 tx,
528 &app_config,
529 &crate::library::entity_snapshots::CourseSettingsSnapshot {
530 chapter_locking_enabled,
531 ..live.clone()
532 },
533 &live,
534 false,
535 &crate::courses::TextEmbeddings::default(),
536 )
537 .await
538 .unwrap();
539 }
540
541 #[tokio::test]
542 async fn get_status_returns_none_when_no_status_exists() {
543 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
544 let chapter = crate::chapters::insert(
545 tx.as_mut(),
546 PKeyPolicy::Generate,
547 &crate::chapters::NewChapter {
548 name: "Test Chapter".to_string(),
549 color: None,
550 course_id: course,
551 chapter_number: 1,
552 front_page_id: None,
553 opens_at: None,
554 deadline: None,
555 course_module_id: Some(course_module.id),
556 },
557 )
558 .await
559 .unwrap();
560
561 let status = get_or_init_status(tx.as_mut(), user, chapter, None, None)
562 .await
563 .unwrap();
564 assert_eq!(status, None);
565 }
566
567 #[tokio::test]
568 async fn unlock_chapter_creates_unlocked_status() {
569 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
570 let chapter = crate::chapters::insert(
571 tx.as_mut(),
572 PKeyPolicy::Generate,
573 &crate::chapters::NewChapter {
574 name: "Test Chapter".to_string(),
575 color: None,
576 course_id: course,
577 chapter_number: 1,
578 front_page_id: None,
579 opens_at: None,
580 deadline: None,
581 course_module_id: Some(course_module.id),
582 },
583 )
584 .await
585 .unwrap();
586
587 let status = unlock_chapter(tx.as_mut(), user, chapter, course)
588 .await
589 .unwrap();
590 assert_eq!(status.status, ChapterLockingStatus::Unlocked);
591 assert_eq!(status.user_id, user);
592 assert_eq!(status.chapter_id, chapter);
593
594 let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
595 .await
596 .unwrap();
597 assert_eq!(retrieved_status, Some(ChapterLockingStatus::Unlocked));
598 }
599
600 #[tokio::test]
601 async fn complete_and_lock_chapter_creates_completed_status() {
602 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
603 let chapter = crate::chapters::insert(
604 tx.as_mut(),
605 PKeyPolicy::Generate,
606 &crate::chapters::NewChapter {
607 name: "Test Chapter".to_string(),
608 color: None,
609 course_id: course,
610 chapter_number: 1,
611 front_page_id: None,
612 opens_at: None,
613 deadline: None,
614 course_module_id: Some(course_module.id),
615 },
616 )
617 .await
618 .unwrap();
619
620 let status = complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
621 .await
622 .unwrap();
623 assert_eq!(status.status, ChapterLockingStatus::CompletedAndLocked);
624 assert_eq!(status.user_id, user);
625 assert_eq!(status.chapter_id, chapter);
626
627 let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
628 .await
629 .unwrap();
630 assert_eq!(
631 retrieved_status,
632 Some(ChapterLockingStatus::CompletedAndLocked)
633 );
634 }
635
636 #[tokio::test]
637 async fn unlock_then_complete_chapter_updates_status() {
638 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
639 let app_config = init_app_conf().expect("Application Configuration initialization failed");
640 let chapter = crate::chapters::insert(
641 tx.as_mut(),
642 PKeyPolicy::Generate,
643 &crate::chapters::NewChapter {
644 name: "Test Chapter".to_string(),
645 color: None,
646 course_id: course,
647 chapter_number: 1,
648 front_page_id: None,
649 opens_at: None,
650 deadline: None,
651 course_module_id: Some(course_module.id),
652 },
653 )
654 .await
655 .unwrap();
656
657 let existing_course = crate::courses::get_course(tx.as_mut(), course)
658 .await
659 .unwrap();
660 crate::courses::update_course(
661 tx.as_mut(),
662 &app_config,
663 course,
664 crate::courses::CourseUpdate {
665 flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
666 chapter_locking_enabled: true,
667 ..crate::courses::CourseUpdate::from(&existing_course)
668 },
669 user,
670 false,
671 )
672 .await
673 .unwrap();
674
675 unlock_chapter(tx.as_mut(), user, chapter, course)
676 .await
677 .unwrap();
678 let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
679 .await
680 .unwrap();
681 assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
682
683 complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
684 .await
685 .unwrap();
686 let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
687 .await
688 .unwrap();
689 assert_eq!(status, Some(ChapterLockingStatus::CompletedAndLocked));
690 }
691
692 #[tokio::test]
693 async fn get_or_init_all_for_course_returns_all_statuses() {
694 insert_data!(:tx, :user, :org, course: course);
695 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
698 .await
699 .unwrap();
700 let base_module = all_modules
701 .into_iter()
702 .find(|m| m.order_number == 0)
703 .unwrap();
704
705 let chapter1 = crate::chapters::insert(
706 tx.as_mut(),
707 PKeyPolicy::Generate,
708 &crate::chapters::NewChapter {
709 name: "Chapter 1".to_string(),
710 color: None,
711 course_id: course,
712 chapter_number: 1,
713 front_page_id: None,
714 opens_at: None,
715 deadline: None,
716 course_module_id: Some(base_module.id),
717 },
718 )
719 .await
720 .unwrap();
721 let chapter2 = crate::chapters::insert(
722 tx.as_mut(),
723 PKeyPolicy::Generate,
724 &crate::chapters::NewChapter {
725 name: "Chapter 2".to_string(),
726 color: None,
727 course_id: course,
728 chapter_number: 2,
729 front_page_id: None,
730 opens_at: None,
731 deadline: None,
732 course_module_id: Some(base_module.id),
733 },
734 )
735 .await
736 .unwrap();
737
738 unlock_chapter(tx.as_mut(), user, chapter1, course)
739 .await
740 .unwrap();
741 complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
742 .await
743 .unwrap();
744
745 let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
746 .await
747 .unwrap();
748 assert_eq!(statuses.len(), 2);
749 assert!(
750 statuses
751 .iter()
752 .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
753 );
754 assert!(
755 statuses.iter().any(|s| s.chapter_id == chapter2
756 && s.status == ChapterLockingStatus::CompletedAndLocked)
757 );
758 }
759
760 #[tokio::test]
761 async fn get_or_init_all_for_course_unlocks_first_chapter_when_all_not_unlocked_yet() {
762 insert_data!(:tx, :user, :org, course: course);
763 let app_config = init_app_conf().expect("Application Configuration initialization failed");
764
765 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
766 .await
767 .unwrap();
768 let base_module = all_modules
769 .into_iter()
770 .find(|m| m.order_number == 0)
771 .unwrap();
772
773 let chapter1 = crate::chapters::insert(
774 tx.as_mut(),
775 PKeyPolicy::Generate,
776 &crate::chapters::NewChapter {
777 name: "Chapter 1".to_string(),
778 color: None,
779 course_id: course,
780 chapter_number: 1,
781 front_page_id: None,
782 opens_at: None,
783 deadline: None,
784 course_module_id: Some(base_module.id),
785 },
786 )
787 .await
788 .unwrap();
789
790 let chapter2 = crate::chapters::insert(
792 tx.as_mut(),
793 PKeyPolicy::Generate,
794 &crate::chapters::NewChapter {
795 name: "Chapter 2".to_string(),
796 color: None,
797 course_id: course,
798 chapter_number: 2,
799 front_page_id: None,
800 opens_at: None,
801 deadline: None,
802 course_module_id: Some(base_module.id),
803 },
804 )
805 .await
806 .unwrap();
807
808 let existing_course = crate::courses::get_course(tx.as_mut(), course)
810 .await
811 .unwrap();
812
813 crate::courses::update_course(
814 tx.as_mut(),
815 &app_config,
816 course,
817 crate::courses::CourseUpdate {
818 flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
819 chapter_locking_enabled: true,
820 ..crate::courses::CourseUpdate::from(&existing_course)
821 },
822 user,
823 false,
824 )
825 .await
826 .unwrap();
827
828 let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter1, course)
830 .await
831 .unwrap();
832 let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
833 .await
834 .unwrap();
835
836 let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
837 .await
838 .unwrap();
839
840 assert!(!statuses.is_empty());
841 assert!(
842 statuses
843 .iter()
844 .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
845 );
846 }
847
848 #[tokio::test]
849 async fn get_all_for_course_returns_existing_statuses_without_initializing_missing_rows() {
850 insert_data!(
851 :tx,
852 :user,
853 :org,
854 course: course,
855 instance: _instance,
856 :course_module
857 );
858 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
859 let user_2 = crate::users::insert(
860 tx.as_mut(),
861 PKeyPolicy::Generate,
862 &format!("{}@example.com", Uuid::new_v4()),
863 None,
864 None,
865 )
866 .await
867 .unwrap();
868 let chapter = crate::chapters::insert(
869 tx.as_mut(),
870 PKeyPolicy::Generate,
871 &crate::chapters::NewChapter {
872 name: "Chapter 1".to_string(),
873 color: None,
874 course_id: course,
875 chapter_number: 1,
876 front_page_id: None,
877 opens_at: None,
878 deadline: None,
879 course_module_id: Some(course_module.id),
880 },
881 )
882 .await
883 .unwrap();
884
885 unlock_chapter(tx.as_mut(), user, chapter, course)
886 .await
887 .unwrap();
888
889 let course = crate::courses::get_course(tx.as_mut(), course)
890 .await
891 .unwrap();
892 let statuses = get_all_for_course(tx.as_mut(), &course).await.unwrap();
893
894 assert_eq!(statuses.len(), 1);
895 assert_eq!(statuses[0].user_id, user);
896 assert_eq!(statuses[0].chapter_id, chapter);
897 assert_eq!(statuses[0].status, ChapterLockingStatus::Unlocked);
898 assert!(statuses.iter().all(|status| status.user_id != user_2));
899 }
900
901 #[tokio::test]
902 async fn unlock_chapters_for_user_only_updates_selected_chapters() {
903 insert_data!(:tx, :user, :org, course: course);
904 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
905
906 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
907 .await
908 .unwrap();
909 let base_module = all_modules
910 .into_iter()
911 .find(|m| m.order_number == 0)
912 .unwrap();
913
914 let chapter1 = crate::chapters::insert(
915 tx.as_mut(),
916 PKeyPolicy::Generate,
917 &crate::chapters::NewChapter {
918 name: "Chapter 1".to_string(),
919 color: None,
920 course_id: course,
921 chapter_number: 1,
922 front_page_id: None,
923 opens_at: None,
924 deadline: None,
925 course_module_id: Some(base_module.id),
926 },
927 )
928 .await
929 .unwrap();
930 let chapter2 = crate::chapters::insert(
931 tx.as_mut(),
932 PKeyPolicy::Generate,
933 &crate::chapters::NewChapter {
934 name: "Chapter 2".to_string(),
935 color: None,
936 course_id: course,
937 chapter_number: 2,
938 front_page_id: None,
939 opens_at: None,
940 deadline: None,
941 course_module_id: Some(base_module.id),
942 },
943 )
944 .await
945 .unwrap();
946
947 complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
948 .await
949 .unwrap();
950 complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
951 .await
952 .unwrap();
953
954 unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1])
955 .await
956 .unwrap();
957
958 let chapter1_status = get_or_init_status(tx.as_mut(), user, chapter1, Some(course), None)
959 .await
960 .unwrap();
961 let chapter2_status = get_or_init_status(tx.as_mut(), user, chapter2, Some(course), None)
962 .await
963 .unwrap();
964
965 assert_eq!(chapter1_status, Some(ChapterLockingStatus::Unlocked));
966 assert_eq!(
967 chapter2_status,
968 Some(ChapterLockingStatus::CompletedAndLocked)
969 );
970 }
971
972 #[tokio::test]
973 async fn unlock_chapters_for_user_does_not_unlock_not_unlocked_yet_statuses() {
974 insert_data!(:tx, :user, :org, course: course);
975 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
976
977 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
978 .await
979 .unwrap();
980 let base_module = all_modules
981 .into_iter()
982 .find(|m| m.order_number == 0)
983 .unwrap();
984
985 let chapter1 = crate::chapters::insert(
986 tx.as_mut(),
987 PKeyPolicy::Generate,
988 &crate::chapters::NewChapter {
989 name: "Chapter 1".to_string(),
990 color: None,
991 course_id: course,
992 chapter_number: 1,
993 front_page_id: None,
994 opens_at: None,
995 deadline: None,
996 course_module_id: Some(base_module.id),
997 },
998 )
999 .await
1000 .unwrap();
1001 let chapter2 = crate::chapters::insert(
1002 tx.as_mut(),
1003 PKeyPolicy::Generate,
1004 &crate::chapters::NewChapter {
1005 name: "Chapter 2".to_string(),
1006 color: None,
1007 course_id: course,
1008 chapter_number: 2,
1009 front_page_id: None,
1010 opens_at: None,
1011 deadline: None,
1012 course_module_id: Some(base_module.id),
1013 },
1014 )
1015 .await
1016 .unwrap();
1017
1018 complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
1019 .await
1020 .unwrap();
1021 ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
1022 .await
1023 .unwrap();
1024
1025 unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1, chapter2])
1026 .await
1027 .unwrap();
1028
1029 let chapter1_status = get_or_init_status(tx.as_mut(), user, chapter1, Some(course), None)
1030 .await
1031 .unwrap();
1032 let chapter2_status = get_or_init_status(tx.as_mut(), user, chapter2, Some(course), None)
1033 .await
1034 .unwrap();
1035
1036 assert_eq!(chapter1_status, Some(ChapterLockingStatus::Unlocked));
1037 assert_eq!(chapter2_status, Some(ChapterLockingStatus::NotUnlockedYet));
1038 }
1039
1040 #[tokio::test]
1041 async fn unlock_chapters_for_user_soft_deletes_rows_when_locking_is_disabled() {
1042 insert_data!(:tx, :user, :org, course: course);
1043 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
1044
1045 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
1046 .await
1047 .unwrap();
1048 let base_module = all_modules
1049 .into_iter()
1050 .find(|m| m.order_number == 0)
1051 .unwrap();
1052
1053 let chapter1 = crate::chapters::insert(
1054 tx.as_mut(),
1055 PKeyPolicy::Generate,
1056 &crate::chapters::NewChapter {
1057 name: "Chapter 1".to_string(),
1058 color: None,
1059 course_id: course,
1060 chapter_number: 1,
1061 front_page_id: None,
1062 opens_at: None,
1063 deadline: None,
1064 course_module_id: Some(base_module.id),
1065 },
1066 )
1067 .await
1068 .unwrap();
1069 let chapter2 = crate::chapters::insert(
1070 tx.as_mut(),
1071 PKeyPolicy::Generate,
1072 &crate::chapters::NewChapter {
1073 name: "Chapter 2".to_string(),
1074 color: None,
1075 course_id: course,
1076 chapter_number: 2,
1077 front_page_id: None,
1078 opens_at: None,
1079 deadline: None,
1080 course_module_id: Some(base_module.id),
1081 },
1082 )
1083 .await
1084 .unwrap();
1085
1086 complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
1087 .await
1088 .unwrap();
1089 complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
1090 .await
1091 .unwrap();
1092
1093 set_course_chapter_locking_enabled(tx.as_mut(), course, false).await;
1094
1095 unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1])
1096 .await
1097 .unwrap();
1098
1099 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
1100 let enabled_course = crate::courses::get_course(tx.as_mut(), course)
1101 .await
1102 .unwrap();
1103 let statuses = get_for_user_and_course(tx.as_mut(), user, &enabled_course)
1104 .await
1105 .unwrap();
1106
1107 assert_eq!(statuses.len(), 1);
1108 assert_eq!(statuses[0].chapter_id, chapter2);
1109 assert_eq!(statuses[0].status, ChapterLockingStatus::CompletedAndLocked);
1110 }
1111}