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