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