Skip to main content

headless_lms_models/
user_chapter_locking_statuses.rs

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    /// Chapter is unlocked and exercises can be submitted.
10    Unlocked,
11    /// Chapter content is accessible, but exercises are locked (chapter has been completed).
12    CompletedAndLocked,
13    /// Chapter is locked because previous chapters are not completed.
14    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
321/// Returns all chapter locking statuses for the given users in a course.
322pub 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
349/// Returns all chapter locking statuses for a specific user in a course.
350pub 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
377/// Creates a status row with `not_unlocked_yet` status if one doesn't exist.
378/// If a row already exists (with any status), returns the existing row without modifying it.
379/// This function does not overwrite existing statuses.
380pub 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
426/// Unlocks the provided chapters for a user within a course.
427pub 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    /// Updates chapter locking on a course for test setup.
484    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        // Use the base module (order_number == 0) so that the unlocking logic,
689        // which operates on the base module, affects these chapters.
690        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        // insert a second chapter to ensure only the first is auto-unlocked
784        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        // Enable chapter locking for the course
802        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        // Ensure we start from a state where all chapters are not_unlocked_yet
834        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}