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 let course_locking_enabled = course.chapter_locking_enabled;
233
234 if course_locking_enabled {
235 sqlx::query!(
236 r#"
237INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
238SELECT $1, chapters.id, $2, 'not_unlocked_yet'::chapter_locking_status, NULL
239FROM chapters
240WHERE chapters.course_id = $2
241 AND chapters.deleted_at IS NULL
242 AND NOT EXISTS (
243 SELECT 1
244 FROM user_chapter_locking_statuses
245 WHERE user_chapter_locking_statuses.user_id = $1
246 AND user_chapter_locking_statuses.chapter_id = chapters.id
247 AND user_chapter_locking_statuses.deleted_at IS NULL
248 )
249ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
250 "#,
251 user_id,
252 course_id
253 )
254 .execute(&mut *conn)
255 .await?;
256 }
257
258 async fn get_statuses_for_user_and_course(
259 conn: &mut PgConnection,
260 user_id: Uuid,
261 course_id: Uuid,
262 ) -> ModelResult<Vec<UserChapterLockingStatus>> {
263 let rows = sqlx::query_as!(
264 UserChapterLockingStatus,
265 r#"
266SELECT *
267FROM user_chapter_locking_statuses
268WHERE user_id = $1
269 AND course_id = $2
270 AND deleted_at IS NULL
271 "#,
272 user_id,
273 course_id
274 )
275 .fetch_all(&mut *conn)
276 .await?;
277
278 Ok(rows)
279 }
280
281 let mut statuses = get_statuses_for_user_and_course(conn, user_id, course_id).await?;
282
283 if course_locking_enabled
284 && !statuses.is_empty()
285 && statuses
286 .iter()
287 .all(|s| matches!(s.status, ChapterLockingStatus::NotUnlockedYet))
288 {
289 crate::chapters::unlock_first_chapters_for_user(conn, user_id, course_id).await?;
290
291 statuses = get_statuses_for_user_and_course(conn, user_id, course_id).await?;
292 }
293
294 Ok(statuses)
295}
296
297pub async fn get_all_for_course(
298 conn: &mut PgConnection,
299 course: &crate::courses::Course,
300) -> ModelResult<Vec<UserChapterLockingStatus>> {
301 if !course.chapter_locking_enabled {
302 return Ok(Vec::new());
303 }
304
305 let rows = sqlx::query_as!(
306 UserChapterLockingStatus,
307 r#"
308SELECT *
309FROM user_chapter_locking_statuses
310WHERE course_id = $1
311 AND deleted_at IS NULL
312 "#,
313 course.id
314 )
315 .fetch_all(&mut *conn)
316 .await?;
317
318 Ok(rows)
319}
320
321pub async fn get_for_users_and_course(
323 conn: &mut PgConnection,
324 user_ids: &[Uuid],
325 course: &crate::courses::Course,
326) -> ModelResult<Vec<UserChapterLockingStatus>> {
327 if !course.chapter_locking_enabled {
328 return Ok(Vec::new());
329 }
330
331 let rows = sqlx::query_as!(
332 UserChapterLockingStatus,
333 r#"
334SELECT *
335FROM user_chapter_locking_statuses
336WHERE course_id = $1
337 AND user_id = ANY($2::uuid[])
338 AND deleted_at IS NULL
339 "#,
340 course.id,
341 user_ids
342 )
343 .fetch_all(&mut *conn)
344 .await?;
345
346 Ok(rows)
347}
348
349pub async fn get_for_user_and_course(
351 conn: &mut PgConnection,
352 user_id: Uuid,
353 course: &crate::courses::Course,
354) -> ModelResult<Vec<UserChapterLockingStatus>> {
355 if !course.chapter_locking_enabled {
356 return Ok(Vec::new());
357 }
358
359 let rows = sqlx::query_as!(
360 UserChapterLockingStatus,
361 r#"
362SELECT *
363FROM user_chapter_locking_statuses
364WHERE user_id = $1
365 AND course_id = $2
366 AND deleted_at IS NULL
367 "#,
368 user_id,
369 course.id
370 )
371 .fetch_all(&mut *conn)
372 .await?;
373
374 Ok(rows)
375}
376
377pub async fn ensure_not_unlocked_yet_status(
381 conn: &mut PgConnection,
382 user_id: Uuid,
383 chapter_id: Uuid,
384 course_id: Uuid,
385) -> ModelResult<UserChapterLockingStatus> {
386 let res: Option<UserChapterLockingStatus> = sqlx::query_as!(
387 UserChapterLockingStatus,
388 r#"
389INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
390VALUES ($1, $2, $3, 'not_unlocked_yet'::chapter_locking_status, NULL)
391ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
392RETURNING *
393 "#,
394 user_id,
395 chapter_id,
396 course_id
397 )
398 .fetch_optional(&mut *conn)
399 .await?;
400
401 if let Some(status) = res {
402 return Ok(status);
403 }
404
405 let retrieved = sqlx::query_as!(
406 UserChapterLockingStatus,
407 r#"
408SELECT *
409FROM user_chapter_locking_statuses
410WHERE user_id = $1
411 AND chapter_id = $2
412 AND deleted_at IS NULL
413 "#,
414 user_id,
415 chapter_id
416 )
417 .fetch_optional(&mut *conn)
418 .await?;
419
420 retrieved.ok_or_else(missing_model_error(
421 ModelErrorType::NotFound,
422 "Failed to ensure not_unlocked_yet status",
423 ))
424}
425
426pub async fn unlock_chapters_for_user(
428 conn: &mut PgConnection,
429 user_id: Uuid,
430 course_id: Uuid,
431 chapter_ids: &[Uuid],
432) -> ModelResult<()> {
433 if chapter_ids.is_empty() {
434 return Ok(());
435 }
436
437 let course = crate::courses::get_course(conn, course_id).await?;
438 if !course.chapter_locking_enabled {
439 sqlx::query!(
440 r#"
441UPDATE user_chapter_locking_statuses
442SET deleted_at = NOW()
443WHERE user_id = $1
444 AND course_id = $2
445 AND chapter_id = ANY($3)
446 AND deleted_at IS NULL
447 "#,
448 user_id,
449 course_id,
450 chapter_ids
451 )
452 .execute(&mut *conn)
453 .await?;
454
455 return Ok(());
456 }
457
458 sqlx::query!(
459 r#"
460UPDATE user_chapter_locking_statuses
461SET status = 'unlocked'::chapter_locking_status, deleted_at = NULL
462WHERE user_id = $1
463 AND course_id = $2
464 AND chapter_id = ANY($3)
465 AND status = 'completed_and_locked'::chapter_locking_status
466 AND deleted_at IS NULL
467 "#,
468 user_id,
469 course_id,
470 chapter_ids
471 )
472 .execute(&mut *conn)
473 .await?;
474
475 Ok(())
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::test_helper::*;
482
483 async fn set_course_chapter_locking_enabled(
485 tx: &mut PgConnection,
486 course_id: Uuid,
487 chapter_locking_enabled: bool,
488 ) {
489 let course_before_update = crate::courses::get_course(tx, course_id).await.unwrap();
490 crate::courses::update_course(
491 tx,
492 course_id,
493 crate::courses::CourseUpdate {
494 chapter_locking_enabled,
495 name: course_before_update.name,
496 description: course_before_update.description,
497 is_draft: course_before_update.is_draft,
498 is_test_mode: course_before_update.is_test_mode,
499 can_add_chatbot: course_before_update.can_add_chatbot,
500 is_unlisted: course_before_update.is_unlisted,
501 is_joinable_by_code_only: course_before_update.is_joinable_by_code_only,
502 ask_marketing_consent: course_before_update.ask_marketing_consent,
503 flagged_answers_threshold: course_before_update
504 .flagged_answers_threshold
505 .unwrap_or_default(),
506 flagged_answers_skip_manual_review_and_allow_retry: course_before_update
507 .flagged_answers_skip_manual_review_and_allow_retry,
508 closed_at: course_before_update.closed_at,
509 closed_additional_message: course_before_update.closed_additional_message,
510 closed_course_successor_id: course_before_update.closed_course_successor_id,
511 ai_policy: course_before_update.ai_policy,
512 course_material_ai_instructions: course_before_update
513 .course_material_ai_instructions,
514 },
515 )
516 .await
517 .unwrap();
518 }
519
520 #[tokio::test]
521 async fn get_status_returns_none_when_no_status_exists() {
522 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
523 let chapter = crate::chapters::insert(
524 tx.as_mut(),
525 PKeyPolicy::Generate,
526 &crate::chapters::NewChapter {
527 name: "Test Chapter".to_string(),
528 color: None,
529 course_id: course,
530 chapter_number: 1,
531 front_page_id: None,
532 opens_at: None,
533 deadline: None,
534 course_module_id: Some(course_module.id),
535 },
536 )
537 .await
538 .unwrap();
539
540 let status = get_or_init_status(tx.as_mut(), user, chapter, None, None)
541 .await
542 .unwrap();
543 assert_eq!(status, None);
544 }
545
546 #[tokio::test]
547 async fn unlock_chapter_creates_unlocked_status() {
548 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
549 let chapter = crate::chapters::insert(
550 tx.as_mut(),
551 PKeyPolicy::Generate,
552 &crate::chapters::NewChapter {
553 name: "Test Chapter".to_string(),
554 color: None,
555 course_id: course,
556 chapter_number: 1,
557 front_page_id: None,
558 opens_at: None,
559 deadline: None,
560 course_module_id: Some(course_module.id),
561 },
562 )
563 .await
564 .unwrap();
565
566 let status = unlock_chapter(tx.as_mut(), user, chapter, course)
567 .await
568 .unwrap();
569 assert_eq!(status.status, ChapterLockingStatus::Unlocked);
570 assert_eq!(status.user_id, user);
571 assert_eq!(status.chapter_id, chapter);
572
573 let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
574 .await
575 .unwrap();
576 assert_eq!(retrieved_status, Some(ChapterLockingStatus::Unlocked));
577 }
578
579 #[tokio::test]
580 async fn complete_and_lock_chapter_creates_completed_status() {
581 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
582 let chapter = crate::chapters::insert(
583 tx.as_mut(),
584 PKeyPolicy::Generate,
585 &crate::chapters::NewChapter {
586 name: "Test Chapter".to_string(),
587 color: None,
588 course_id: course,
589 chapter_number: 1,
590 front_page_id: None,
591 opens_at: None,
592 deadline: None,
593 course_module_id: Some(course_module.id),
594 },
595 )
596 .await
597 .unwrap();
598
599 let status = complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
600 .await
601 .unwrap();
602 assert_eq!(status.status, ChapterLockingStatus::CompletedAndLocked);
603 assert_eq!(status.user_id, user);
604 assert_eq!(status.chapter_id, chapter);
605
606 let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
607 .await
608 .unwrap();
609 assert_eq!(
610 retrieved_status,
611 Some(ChapterLockingStatus::CompletedAndLocked)
612 );
613 }
614
615 #[tokio::test]
616 async fn unlock_then_complete_chapter_updates_status() {
617 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
618 let chapter = crate::chapters::insert(
619 tx.as_mut(),
620 PKeyPolicy::Generate,
621 &crate::chapters::NewChapter {
622 name: "Test Chapter".to_string(),
623 color: None,
624 course_id: course,
625 chapter_number: 1,
626 front_page_id: None,
627 opens_at: None,
628 deadline: None,
629 course_module_id: Some(course_module.id),
630 },
631 )
632 .await
633 .unwrap();
634
635 let existing_course = crate::courses::get_course(tx.as_mut(), course)
636 .await
637 .unwrap();
638 crate::courses::update_course(
639 tx.as_mut(),
640 course,
641 crate::courses::CourseUpdate {
642 name: existing_course.name,
643 description: existing_course.description,
644 is_draft: existing_course.is_draft,
645 is_test_mode: existing_course.is_test_mode,
646 can_add_chatbot: existing_course.can_add_chatbot,
647 is_unlisted: existing_course.is_unlisted,
648 is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
649 ask_marketing_consent: existing_course.ask_marketing_consent,
650 flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
651 flagged_answers_skip_manual_review_and_allow_retry: existing_course
652 .flagged_answers_skip_manual_review_and_allow_retry,
653 closed_at: existing_course.closed_at,
654 closed_additional_message: existing_course.closed_additional_message,
655 closed_course_successor_id: existing_course.closed_course_successor_id,
656 chapter_locking_enabled: true,
657 ai_policy: existing_course.ai_policy,
658 course_material_ai_instructions: existing_course.course_material_ai_instructions,
659 },
660 )
661 .await
662 .unwrap();
663
664 unlock_chapter(tx.as_mut(), user, chapter, course)
665 .await
666 .unwrap();
667 let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
668 .await
669 .unwrap();
670 assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
671
672 complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
673 .await
674 .unwrap();
675 let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
676 .await
677 .unwrap();
678 assert_eq!(status, Some(ChapterLockingStatus::CompletedAndLocked));
679 }
680
681 #[tokio::test]
682 async fn get_or_init_all_for_course_returns_all_statuses() {
683 insert_data!(:tx, :user, :org, course: course);
684 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
687 .await
688 .unwrap();
689 let base_module = all_modules
690 .into_iter()
691 .find(|m| m.order_number == 0)
692 .unwrap();
693
694 let chapter1 = crate::chapters::insert(
695 tx.as_mut(),
696 PKeyPolicy::Generate,
697 &crate::chapters::NewChapter {
698 name: "Chapter 1".to_string(),
699 color: None,
700 course_id: course,
701 chapter_number: 1,
702 front_page_id: None,
703 opens_at: None,
704 deadline: None,
705 course_module_id: Some(base_module.id),
706 },
707 )
708 .await
709 .unwrap();
710 let chapter2 = crate::chapters::insert(
711 tx.as_mut(),
712 PKeyPolicy::Generate,
713 &crate::chapters::NewChapter {
714 name: "Chapter 2".to_string(),
715 color: None,
716 course_id: course,
717 chapter_number: 2,
718 front_page_id: None,
719 opens_at: None,
720 deadline: None,
721 course_module_id: Some(base_module.id),
722 },
723 )
724 .await
725 .unwrap();
726
727 unlock_chapter(tx.as_mut(), user, chapter1, course)
728 .await
729 .unwrap();
730 complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
731 .await
732 .unwrap();
733
734 let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
735 .await
736 .unwrap();
737 assert_eq!(statuses.len(), 2);
738 assert!(
739 statuses
740 .iter()
741 .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
742 );
743 assert!(
744 statuses.iter().any(|s| s.chapter_id == chapter2
745 && s.status == ChapterLockingStatus::CompletedAndLocked)
746 );
747 }
748
749 #[tokio::test]
750 async fn get_or_init_all_for_course_unlocks_first_chapter_when_all_not_unlocked_yet() {
751 insert_data!(:tx, :user, :org, course: course);
752
753 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
754 .await
755 .unwrap();
756 let base_module = all_modules
757 .into_iter()
758 .find(|m| m.order_number == 0)
759 .unwrap();
760
761 let chapter1 = crate::chapters::insert(
762 tx.as_mut(),
763 PKeyPolicy::Generate,
764 &crate::chapters::NewChapter {
765 name: "Chapter 1".to_string(),
766 color: None,
767 course_id: course,
768 chapter_number: 1,
769 front_page_id: None,
770 opens_at: None,
771 deadline: None,
772 course_module_id: Some(base_module.id),
773 },
774 )
775 .await
776 .unwrap();
777
778 let chapter2 = crate::chapters::insert(
780 tx.as_mut(),
781 PKeyPolicy::Generate,
782 &crate::chapters::NewChapter {
783 name: "Chapter 2".to_string(),
784 color: None,
785 course_id: course,
786 chapter_number: 2,
787 front_page_id: None,
788 opens_at: None,
789 deadline: None,
790 course_module_id: Some(base_module.id),
791 },
792 )
793 .await
794 .unwrap();
795
796 let existing_course = crate::courses::get_course(tx.as_mut(), course)
798 .await
799 .unwrap();
800
801 crate::courses::update_course(
802 tx.as_mut(),
803 course,
804 crate::courses::CourseUpdate {
805 name: existing_course.name,
806 description: existing_course.description,
807 is_draft: existing_course.is_draft,
808 is_test_mode: existing_course.is_test_mode,
809 can_add_chatbot: existing_course.can_add_chatbot,
810 is_unlisted: existing_course.is_unlisted,
811 is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
812 ask_marketing_consent: existing_course.ask_marketing_consent,
813 flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
814 flagged_answers_skip_manual_review_and_allow_retry: existing_course
815 .flagged_answers_skip_manual_review_and_allow_retry,
816 closed_at: existing_course.closed_at,
817 closed_additional_message: existing_course.closed_additional_message,
818 closed_course_successor_id: existing_course.closed_course_successor_id,
819 chapter_locking_enabled: true,
820 ai_policy: existing_course.ai_policy,
821 course_material_ai_instructions: existing_course.course_material_ai_instructions,
822 },
823 )
824 .await
825 .unwrap();
826
827 let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter1, course)
829 .await
830 .unwrap();
831 let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
832 .await
833 .unwrap();
834
835 let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
836 .await
837 .unwrap();
838
839 assert!(!statuses.is_empty());
840 assert!(
841 statuses
842 .iter()
843 .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
844 );
845 }
846
847 #[tokio::test]
848 async fn get_all_for_course_returns_existing_statuses_without_initializing_missing_rows() {
849 insert_data!(
850 :tx,
851 :user,
852 :org,
853 course: course,
854 instance: _instance,
855 :course_module
856 );
857 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
858 let user_2 = crate::users::insert(
859 tx.as_mut(),
860 PKeyPolicy::Generate,
861 &format!("{}@example.com", Uuid::new_v4()),
862 None,
863 None,
864 )
865 .await
866 .unwrap();
867 let chapter = crate::chapters::insert(
868 tx.as_mut(),
869 PKeyPolicy::Generate,
870 &crate::chapters::NewChapter {
871 name: "Chapter 1".to_string(),
872 color: None,
873 course_id: course,
874 chapter_number: 1,
875 front_page_id: None,
876 opens_at: None,
877 deadline: None,
878 course_module_id: Some(course_module.id),
879 },
880 )
881 .await
882 .unwrap();
883
884 unlock_chapter(tx.as_mut(), user, chapter, course)
885 .await
886 .unwrap();
887
888 let course = crate::courses::get_course(tx.as_mut(), course)
889 .await
890 .unwrap();
891 let statuses = get_all_for_course(tx.as_mut(), &course).await.unwrap();
892
893 assert_eq!(statuses.len(), 1);
894 assert_eq!(statuses[0].user_id, user);
895 assert_eq!(statuses[0].chapter_id, chapter);
896 assert_eq!(statuses[0].status, ChapterLockingStatus::Unlocked);
897 assert!(statuses.iter().all(|status| status.user_id != user_2));
898 }
899
900 #[tokio::test]
901 async fn unlock_chapters_for_user_only_updates_selected_chapters() {
902 insert_data!(:tx, :user, :org, course: course);
903 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
904
905 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
906 .await
907 .unwrap();
908 let base_module = all_modules
909 .into_iter()
910 .find(|m| m.order_number == 0)
911 .unwrap();
912
913 let chapter1 = crate::chapters::insert(
914 tx.as_mut(),
915 PKeyPolicy::Generate,
916 &crate::chapters::NewChapter {
917 name: "Chapter 1".to_string(),
918 color: None,
919 course_id: course,
920 chapter_number: 1,
921 front_page_id: None,
922 opens_at: None,
923 deadline: None,
924 course_module_id: Some(base_module.id),
925 },
926 )
927 .await
928 .unwrap();
929 let chapter2 = crate::chapters::insert(
930 tx.as_mut(),
931 PKeyPolicy::Generate,
932 &crate::chapters::NewChapter {
933 name: "Chapter 2".to_string(),
934 color: None,
935 course_id: course,
936 chapter_number: 2,
937 front_page_id: None,
938 opens_at: None,
939 deadline: None,
940 course_module_id: Some(base_module.id),
941 },
942 )
943 .await
944 .unwrap();
945
946 complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
947 .await
948 .unwrap();
949 complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
950 .await
951 .unwrap();
952
953 unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1])
954 .await
955 .unwrap();
956
957 let chapter1_status = get_or_init_status(tx.as_mut(), user, chapter1, Some(course), None)
958 .await
959 .unwrap();
960 let chapter2_status = get_or_init_status(tx.as_mut(), user, chapter2, Some(course), None)
961 .await
962 .unwrap();
963
964 assert_eq!(chapter1_status, Some(ChapterLockingStatus::Unlocked));
965 assert_eq!(
966 chapter2_status,
967 Some(ChapterLockingStatus::CompletedAndLocked)
968 );
969 }
970
971 #[tokio::test]
972 async fn unlock_chapters_for_user_does_not_unlock_not_unlocked_yet_statuses() {
973 insert_data!(:tx, :user, :org, course: course);
974 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
975
976 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
977 .await
978 .unwrap();
979 let base_module = all_modules
980 .into_iter()
981 .find(|m| m.order_number == 0)
982 .unwrap();
983
984 let chapter1 = crate::chapters::insert(
985 tx.as_mut(),
986 PKeyPolicy::Generate,
987 &crate::chapters::NewChapter {
988 name: "Chapter 1".to_string(),
989 color: None,
990 course_id: course,
991 chapter_number: 1,
992 front_page_id: None,
993 opens_at: None,
994 deadline: None,
995 course_module_id: Some(base_module.id),
996 },
997 )
998 .await
999 .unwrap();
1000 let chapter2 = crate::chapters::insert(
1001 tx.as_mut(),
1002 PKeyPolicy::Generate,
1003 &crate::chapters::NewChapter {
1004 name: "Chapter 2".to_string(),
1005 color: None,
1006 course_id: course,
1007 chapter_number: 2,
1008 front_page_id: None,
1009 opens_at: None,
1010 deadline: None,
1011 course_module_id: Some(base_module.id),
1012 },
1013 )
1014 .await
1015 .unwrap();
1016
1017 complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
1018 .await
1019 .unwrap();
1020 ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
1021 .await
1022 .unwrap();
1023
1024 unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1, chapter2])
1025 .await
1026 .unwrap();
1027
1028 let chapter1_status = get_or_init_status(tx.as_mut(), user, chapter1, Some(course), None)
1029 .await
1030 .unwrap();
1031 let chapter2_status = get_or_init_status(tx.as_mut(), user, chapter2, Some(course), None)
1032 .await
1033 .unwrap();
1034
1035 assert_eq!(chapter1_status, Some(ChapterLockingStatus::Unlocked));
1036 assert_eq!(chapter2_status, Some(ChapterLockingStatus::NotUnlockedYet));
1037 }
1038
1039 #[tokio::test]
1040 async fn unlock_chapters_for_user_soft_deletes_rows_when_locking_is_disabled() {
1041 insert_data!(:tx, :user, :org, course: course);
1042 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
1043
1044 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
1045 .await
1046 .unwrap();
1047 let base_module = all_modules
1048 .into_iter()
1049 .find(|m| m.order_number == 0)
1050 .unwrap();
1051
1052 let chapter1 = crate::chapters::insert(
1053 tx.as_mut(),
1054 PKeyPolicy::Generate,
1055 &crate::chapters::NewChapter {
1056 name: "Chapter 1".to_string(),
1057 color: None,
1058 course_id: course,
1059 chapter_number: 1,
1060 front_page_id: None,
1061 opens_at: None,
1062 deadline: None,
1063 course_module_id: Some(base_module.id),
1064 },
1065 )
1066 .await
1067 .unwrap();
1068 let chapter2 = crate::chapters::insert(
1069 tx.as_mut(),
1070 PKeyPolicy::Generate,
1071 &crate::chapters::NewChapter {
1072 name: "Chapter 2".to_string(),
1073 color: None,
1074 course_id: course,
1075 chapter_number: 2,
1076 front_page_id: None,
1077 opens_at: None,
1078 deadline: None,
1079 course_module_id: Some(base_module.id),
1080 },
1081 )
1082 .await
1083 .unwrap();
1084
1085 complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
1086 .await
1087 .unwrap();
1088 complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
1089 .await
1090 .unwrap();
1091
1092 set_course_chapter_locking_enabled(tx.as_mut(), course, false).await;
1093
1094 unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1])
1095 .await
1096 .unwrap();
1097
1098 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
1099 let enabled_course = crate::courses::get_course(tx.as_mut(), course)
1100 .await
1101 .unwrap();
1102 let statuses = get_for_user_and_course(tx.as_mut(), user, &enabled_course)
1103 .await
1104 .unwrap();
1105
1106 assert_eq!(statuses.len(), 1);
1107 assert_eq!(statuses[0].chapter_id, chapter2);
1108 assert_eq!(statuses[0].status, ChapterLockingStatus::CompletedAndLocked);
1109 }
1110}