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