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 app_config = init_app_conf().expect("Application Configuration initialization failed");
490 let course_before_update = crate::courses::get_course(tx, course_id).await.unwrap();
491 crate::courses::update_course(
492 tx,
493 &app_config,
494 course_id,
495 crate::courses::CourseUpdate {
496 chapter_locking_enabled,
497 name: course_before_update.name,
498 description: course_before_update.description,
499 is_draft: course_before_update.is_draft,
500 is_test_mode: course_before_update.is_test_mode,
501 can_add_chatbot: course_before_update.can_add_chatbot,
502 is_unlisted: course_before_update.is_unlisted,
503 is_joinable_by_code_only: course_before_update.is_joinable_by_code_only,
504 ask_marketing_consent: course_before_update.ask_marketing_consent,
505 flagged_answers_threshold: course_before_update
506 .flagged_answers_threshold
507 .unwrap_or_default(),
508 flagged_answers_skip_manual_review_and_allow_retry: course_before_update
509 .flagged_answers_skip_manual_review_and_allow_retry,
510 closed_at: course_before_update.closed_at,
511 closed_additional_message: course_before_update.closed_additional_message,
512 closed_course_successor_id: course_before_update.closed_course_successor_id,
513 ai_policy: course_before_update.ai_policy,
514 course_material_ai_instructions: course_before_update
515 .course_material_ai_instructions,
516 },
517 )
518 .await
519 .unwrap();
520 }
521
522 #[tokio::test]
523 async fn get_status_returns_none_when_no_status_exists() {
524 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
525 let chapter = crate::chapters::insert(
526 tx.as_mut(),
527 PKeyPolicy::Generate,
528 &crate::chapters::NewChapter {
529 name: "Test Chapter".to_string(),
530 color: None,
531 course_id: course,
532 chapter_number: 1,
533 front_page_id: None,
534 opens_at: None,
535 deadline: None,
536 course_module_id: Some(course_module.id),
537 },
538 )
539 .await
540 .unwrap();
541
542 let status = get_or_init_status(tx.as_mut(), user, chapter, None, None)
543 .await
544 .unwrap();
545 assert_eq!(status, None);
546 }
547
548 #[tokio::test]
549 async fn unlock_chapter_creates_unlocked_status() {
550 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
551 let chapter = crate::chapters::insert(
552 tx.as_mut(),
553 PKeyPolicy::Generate,
554 &crate::chapters::NewChapter {
555 name: "Test Chapter".to_string(),
556 color: None,
557 course_id: course,
558 chapter_number: 1,
559 front_page_id: None,
560 opens_at: None,
561 deadline: None,
562 course_module_id: Some(course_module.id),
563 },
564 )
565 .await
566 .unwrap();
567
568 let status = unlock_chapter(tx.as_mut(), user, chapter, course)
569 .await
570 .unwrap();
571 assert_eq!(status.status, ChapterLockingStatus::Unlocked);
572 assert_eq!(status.user_id, user);
573 assert_eq!(status.chapter_id, chapter);
574
575 let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
576 .await
577 .unwrap();
578 assert_eq!(retrieved_status, Some(ChapterLockingStatus::Unlocked));
579 }
580
581 #[tokio::test]
582 async fn complete_and_lock_chapter_creates_completed_status() {
583 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
584 let chapter = crate::chapters::insert(
585 tx.as_mut(),
586 PKeyPolicy::Generate,
587 &crate::chapters::NewChapter {
588 name: "Test Chapter".to_string(),
589 color: None,
590 course_id: course,
591 chapter_number: 1,
592 front_page_id: None,
593 opens_at: None,
594 deadline: None,
595 course_module_id: Some(course_module.id),
596 },
597 )
598 .await
599 .unwrap();
600
601 let status = complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
602 .await
603 .unwrap();
604 assert_eq!(status.status, ChapterLockingStatus::CompletedAndLocked);
605 assert_eq!(status.user_id, user);
606 assert_eq!(status.chapter_id, chapter);
607
608 let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
609 .await
610 .unwrap();
611 assert_eq!(
612 retrieved_status,
613 Some(ChapterLockingStatus::CompletedAndLocked)
614 );
615 }
616
617 #[tokio::test]
618 async fn unlock_then_complete_chapter_updates_status() {
619 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
620 let app_config = init_app_conf().expect("Application Configuration initialization failed");
621 let chapter = crate::chapters::insert(
622 tx.as_mut(),
623 PKeyPolicy::Generate,
624 &crate::chapters::NewChapter {
625 name: "Test Chapter".to_string(),
626 color: None,
627 course_id: course,
628 chapter_number: 1,
629 front_page_id: None,
630 opens_at: None,
631 deadline: None,
632 course_module_id: Some(course_module.id),
633 },
634 )
635 .await
636 .unwrap();
637
638 let existing_course = crate::courses::get_course(tx.as_mut(), course)
639 .await
640 .unwrap();
641 crate::courses::update_course(
642 tx.as_mut(),
643 &app_config,
644 course,
645 crate::courses::CourseUpdate {
646 name: existing_course.name,
647 description: existing_course.description,
648 is_draft: existing_course.is_draft,
649 is_test_mode: existing_course.is_test_mode,
650 can_add_chatbot: existing_course.can_add_chatbot,
651 is_unlisted: existing_course.is_unlisted,
652 is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
653 ask_marketing_consent: existing_course.ask_marketing_consent,
654 flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
655 flagged_answers_skip_manual_review_and_allow_retry: existing_course
656 .flagged_answers_skip_manual_review_and_allow_retry,
657 closed_at: existing_course.closed_at,
658 closed_additional_message: existing_course.closed_additional_message,
659 closed_course_successor_id: existing_course.closed_course_successor_id,
660 chapter_locking_enabled: true,
661 ai_policy: existing_course.ai_policy,
662 course_material_ai_instructions: existing_course.course_material_ai_instructions,
663 },
664 )
665 .await
666 .unwrap();
667
668 unlock_chapter(tx.as_mut(), user, chapter, course)
669 .await
670 .unwrap();
671 let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
672 .await
673 .unwrap();
674 assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
675
676 complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
677 .await
678 .unwrap();
679 let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
680 .await
681 .unwrap();
682 assert_eq!(status, Some(ChapterLockingStatus::CompletedAndLocked));
683 }
684
685 #[tokio::test]
686 async fn get_or_init_all_for_course_returns_all_statuses() {
687 insert_data!(:tx, :user, :org, course: course);
688 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
691 .await
692 .unwrap();
693 let base_module = all_modules
694 .into_iter()
695 .find(|m| m.order_number == 0)
696 .unwrap();
697
698 let chapter1 = crate::chapters::insert(
699 tx.as_mut(),
700 PKeyPolicy::Generate,
701 &crate::chapters::NewChapter {
702 name: "Chapter 1".to_string(),
703 color: None,
704 course_id: course,
705 chapter_number: 1,
706 front_page_id: None,
707 opens_at: None,
708 deadline: None,
709 course_module_id: Some(base_module.id),
710 },
711 )
712 .await
713 .unwrap();
714 let chapter2 = crate::chapters::insert(
715 tx.as_mut(),
716 PKeyPolicy::Generate,
717 &crate::chapters::NewChapter {
718 name: "Chapter 2".to_string(),
719 color: None,
720 course_id: course,
721 chapter_number: 2,
722 front_page_id: None,
723 opens_at: None,
724 deadline: None,
725 course_module_id: Some(base_module.id),
726 },
727 )
728 .await
729 .unwrap();
730
731 unlock_chapter(tx.as_mut(), user, chapter1, course)
732 .await
733 .unwrap();
734 complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
735 .await
736 .unwrap();
737
738 let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
739 .await
740 .unwrap();
741 assert_eq!(statuses.len(), 2);
742 assert!(
743 statuses
744 .iter()
745 .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
746 );
747 assert!(
748 statuses.iter().any(|s| s.chapter_id == chapter2
749 && s.status == ChapterLockingStatus::CompletedAndLocked)
750 );
751 }
752
753 #[tokio::test]
754 async fn get_or_init_all_for_course_unlocks_first_chapter_when_all_not_unlocked_yet() {
755 insert_data!(:tx, :user, :org, course: course);
756 let app_config = init_app_conf().expect("Application Configuration initialization failed");
757
758 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
759 .await
760 .unwrap();
761 let base_module = all_modules
762 .into_iter()
763 .find(|m| m.order_number == 0)
764 .unwrap();
765
766 let chapter1 = crate::chapters::insert(
767 tx.as_mut(),
768 PKeyPolicy::Generate,
769 &crate::chapters::NewChapter {
770 name: "Chapter 1".to_string(),
771 color: None,
772 course_id: course,
773 chapter_number: 1,
774 front_page_id: None,
775 opens_at: None,
776 deadline: None,
777 course_module_id: Some(base_module.id),
778 },
779 )
780 .await
781 .unwrap();
782
783 let chapter2 = crate::chapters::insert(
785 tx.as_mut(),
786 PKeyPolicy::Generate,
787 &crate::chapters::NewChapter {
788 name: "Chapter 2".to_string(),
789 color: None,
790 course_id: course,
791 chapter_number: 2,
792 front_page_id: None,
793 opens_at: None,
794 deadline: None,
795 course_module_id: Some(base_module.id),
796 },
797 )
798 .await
799 .unwrap();
800
801 let existing_course = crate::courses::get_course(tx.as_mut(), course)
803 .await
804 .unwrap();
805
806 crate::courses::update_course(
807 tx.as_mut(),
808 &app_config,
809 course,
810 crate::courses::CourseUpdate {
811 name: existing_course.name,
812 description: existing_course.description,
813 is_draft: existing_course.is_draft,
814 is_test_mode: existing_course.is_test_mode,
815 can_add_chatbot: existing_course.can_add_chatbot,
816 is_unlisted: existing_course.is_unlisted,
817 is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
818 ask_marketing_consent: existing_course.ask_marketing_consent,
819 flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
820 flagged_answers_skip_manual_review_and_allow_retry: existing_course
821 .flagged_answers_skip_manual_review_and_allow_retry,
822 closed_at: existing_course.closed_at,
823 closed_additional_message: existing_course.closed_additional_message,
824 closed_course_successor_id: existing_course.closed_course_successor_id,
825 chapter_locking_enabled: true,
826 ai_policy: existing_course.ai_policy,
827 course_material_ai_instructions: existing_course.course_material_ai_instructions,
828 },
829 )
830 .await
831 .unwrap();
832
833 let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter1, course)
835 .await
836 .unwrap();
837 let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
838 .await
839 .unwrap();
840
841 let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
842 .await
843 .unwrap();
844
845 assert!(!statuses.is_empty());
846 assert!(
847 statuses
848 .iter()
849 .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
850 );
851 }
852
853 #[tokio::test]
854 async fn get_all_for_course_returns_existing_statuses_without_initializing_missing_rows() {
855 insert_data!(
856 :tx,
857 :user,
858 :org,
859 course: course,
860 instance: _instance,
861 :course_module
862 );
863 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
864 let user_2 = crate::users::insert(
865 tx.as_mut(),
866 PKeyPolicy::Generate,
867 &format!("{}@example.com", Uuid::new_v4()),
868 None,
869 None,
870 )
871 .await
872 .unwrap();
873 let chapter = crate::chapters::insert(
874 tx.as_mut(),
875 PKeyPolicy::Generate,
876 &crate::chapters::NewChapter {
877 name: "Chapter 1".to_string(),
878 color: None,
879 course_id: course,
880 chapter_number: 1,
881 front_page_id: None,
882 opens_at: None,
883 deadline: None,
884 course_module_id: Some(course_module.id),
885 },
886 )
887 .await
888 .unwrap();
889
890 unlock_chapter(tx.as_mut(), user, chapter, course)
891 .await
892 .unwrap();
893
894 let course = crate::courses::get_course(tx.as_mut(), course)
895 .await
896 .unwrap();
897 let statuses = get_all_for_course(tx.as_mut(), &course).await.unwrap();
898
899 assert_eq!(statuses.len(), 1);
900 assert_eq!(statuses[0].user_id, user);
901 assert_eq!(statuses[0].chapter_id, chapter);
902 assert_eq!(statuses[0].status, ChapterLockingStatus::Unlocked);
903 assert!(statuses.iter().all(|status| status.user_id != user_2));
904 }
905
906 #[tokio::test]
907 async fn unlock_chapters_for_user_only_updates_selected_chapters() {
908 insert_data!(:tx, :user, :org, course: course);
909 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
910
911 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
912 .await
913 .unwrap();
914 let base_module = all_modules
915 .into_iter()
916 .find(|m| m.order_number == 0)
917 .unwrap();
918
919 let chapter1 = crate::chapters::insert(
920 tx.as_mut(),
921 PKeyPolicy::Generate,
922 &crate::chapters::NewChapter {
923 name: "Chapter 1".to_string(),
924 color: None,
925 course_id: course,
926 chapter_number: 1,
927 front_page_id: None,
928 opens_at: None,
929 deadline: None,
930 course_module_id: Some(base_module.id),
931 },
932 )
933 .await
934 .unwrap();
935 let chapter2 = crate::chapters::insert(
936 tx.as_mut(),
937 PKeyPolicy::Generate,
938 &crate::chapters::NewChapter {
939 name: "Chapter 2".to_string(),
940 color: None,
941 course_id: course,
942 chapter_number: 2,
943 front_page_id: None,
944 opens_at: None,
945 deadline: None,
946 course_module_id: Some(base_module.id),
947 },
948 )
949 .await
950 .unwrap();
951
952 complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
953 .await
954 .unwrap();
955 complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
956 .await
957 .unwrap();
958
959 unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1])
960 .await
961 .unwrap();
962
963 let chapter1_status = get_or_init_status(tx.as_mut(), user, chapter1, Some(course), None)
964 .await
965 .unwrap();
966 let chapter2_status = get_or_init_status(tx.as_mut(), user, chapter2, Some(course), None)
967 .await
968 .unwrap();
969
970 assert_eq!(chapter1_status, Some(ChapterLockingStatus::Unlocked));
971 assert_eq!(
972 chapter2_status,
973 Some(ChapterLockingStatus::CompletedAndLocked)
974 );
975 }
976
977 #[tokio::test]
978 async fn unlock_chapters_for_user_does_not_unlock_not_unlocked_yet_statuses() {
979 insert_data!(:tx, :user, :org, course: course);
980 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
981
982 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
983 .await
984 .unwrap();
985 let base_module = all_modules
986 .into_iter()
987 .find(|m| m.order_number == 0)
988 .unwrap();
989
990 let chapter1 = crate::chapters::insert(
991 tx.as_mut(),
992 PKeyPolicy::Generate,
993 &crate::chapters::NewChapter {
994 name: "Chapter 1".to_string(),
995 color: None,
996 course_id: course,
997 chapter_number: 1,
998 front_page_id: None,
999 opens_at: None,
1000 deadline: None,
1001 course_module_id: Some(base_module.id),
1002 },
1003 )
1004 .await
1005 .unwrap();
1006 let chapter2 = crate::chapters::insert(
1007 tx.as_mut(),
1008 PKeyPolicy::Generate,
1009 &crate::chapters::NewChapter {
1010 name: "Chapter 2".to_string(),
1011 color: None,
1012 course_id: course,
1013 chapter_number: 2,
1014 front_page_id: None,
1015 opens_at: None,
1016 deadline: None,
1017 course_module_id: Some(base_module.id),
1018 },
1019 )
1020 .await
1021 .unwrap();
1022
1023 complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
1024 .await
1025 .unwrap();
1026 ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
1027 .await
1028 .unwrap();
1029
1030 unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1, chapter2])
1031 .await
1032 .unwrap();
1033
1034 let chapter1_status = get_or_init_status(tx.as_mut(), user, chapter1, Some(course), None)
1035 .await
1036 .unwrap();
1037 let chapter2_status = get_or_init_status(tx.as_mut(), user, chapter2, Some(course), None)
1038 .await
1039 .unwrap();
1040
1041 assert_eq!(chapter1_status, Some(ChapterLockingStatus::Unlocked));
1042 assert_eq!(chapter2_status, Some(ChapterLockingStatus::NotUnlockedYet));
1043 }
1044
1045 #[tokio::test]
1046 async fn unlock_chapters_for_user_soft_deletes_rows_when_locking_is_disabled() {
1047 insert_data!(:tx, :user, :org, course: course);
1048 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
1049
1050 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
1051 .await
1052 .unwrap();
1053 let base_module = all_modules
1054 .into_iter()
1055 .find(|m| m.order_number == 0)
1056 .unwrap();
1057
1058 let chapter1 = crate::chapters::insert(
1059 tx.as_mut(),
1060 PKeyPolicy::Generate,
1061 &crate::chapters::NewChapter {
1062 name: "Chapter 1".to_string(),
1063 color: None,
1064 course_id: course,
1065 chapter_number: 1,
1066 front_page_id: None,
1067 opens_at: None,
1068 deadline: None,
1069 course_module_id: Some(base_module.id),
1070 },
1071 )
1072 .await
1073 .unwrap();
1074 let chapter2 = crate::chapters::insert(
1075 tx.as_mut(),
1076 PKeyPolicy::Generate,
1077 &crate::chapters::NewChapter {
1078 name: "Chapter 2".to_string(),
1079 color: None,
1080 course_id: course,
1081 chapter_number: 2,
1082 front_page_id: None,
1083 opens_at: None,
1084 deadline: None,
1085 course_module_id: Some(base_module.id),
1086 },
1087 )
1088 .await
1089 .unwrap();
1090
1091 complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
1092 .await
1093 .unwrap();
1094 complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
1095 .await
1096 .unwrap();
1097
1098 set_course_chapter_locking_enabled(tx.as_mut(), course, false).await;
1099
1100 unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1])
1101 .await
1102 .unwrap();
1103
1104 set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
1105 let enabled_course = crate::courses::get_course(tx.as_mut(), course)
1106 .await
1107 .unwrap();
1108 let statuses = get_for_user_and_course(tx.as_mut(), user, &enabled_course)
1109 .await
1110 .unwrap();
1111
1112 assert_eq!(statuses.len(), 1);
1113 assert_eq!(statuses[0].chapter_id, chapter2);
1114 assert_eq!(statuses[0].status, ChapterLockingStatus::CompletedAndLocked);
1115 }
1116}