headless_lms_server/controllers/course_material/
chapters.rs

1//! Controllers for requests starting with `/api/v0/course_material/chapters`.
2
3use models::chapters::ChapterLockPreview;
4use models::pages::{Page, PageVisibility, PageWithExercises};
5use models::user_chapter_locking_statuses::{self, ChapterLockingStatus};
6
7use crate::domain::authorization::authorize_access_to_course_material;
8use crate::prelude::*;
9
10/**
11GET `/api/v0/course-material/chapters/:chapter_id/pages` - Returns a list of pages in chapter.
12*/
13#[instrument(skip(pool))]
14async fn get_public_chapter_pages(
15    chapter_id: web::Path<Uuid>,
16    pool: web::Data<PgPool>,
17    auth: Option<AuthUser>,
18) -> ControllerResult<web::Json<Vec<Page>>> {
19    let mut conn = pool.acquire().await?;
20    let token = authorize(
21        &mut conn,
22        Act::View,
23        auth.map(|u| u.id),
24        Res::Chapter(*chapter_id),
25    )
26    .await?;
27    let chapter_pages: Vec<Page> = models::pages::get_course_pages_by_chapter_id_and_visibility(
28        &mut conn,
29        *chapter_id,
30        PageVisibility::Public,
31    )
32    .await?;
33    token.authorized_ok(web::Json(chapter_pages))
34}
35
36/**
37GET `/api/v0/course-material/chapters/:chapter_id/exercises` - Returns a list of pages and its exercises in chapter.
38*/
39#[instrument(skip(pool))]
40async fn get_chapters_exercises(
41    chapter_id: web::Path<Uuid>,
42    pool: web::Data<PgPool>,
43    auth: Option<AuthUser>,
44) -> ControllerResult<web::Json<Vec<PageWithExercises>>> {
45    let mut conn = pool.acquire().await?;
46    let token = authorize(
47        &mut conn,
48        Act::View,
49        auth.map(|u| u.id),
50        Res::Chapter(*chapter_id),
51    )
52    .await?;
53
54    let chapter_pages_with_exercises =
55        models::pages::get_chapters_pages_with_exercises(&mut conn, *chapter_id).await?;
56    token.authorized_ok(web::Json(chapter_pages_with_exercises))
57}
58
59/**
60GET `/api/v0/course-material/chapters/:chapter_id/pages-exclude-mainfrontpage` - Returns a list of pages in chapter mainfrontpage excluded.
61*/
62#[instrument(skip(pool))]
63async fn get_chapters_pages_without_main_frontpage(
64    chapter_id: web::Path<Uuid>,
65    pool: web::Data<PgPool>,
66    auth: Option<AuthUser>,
67) -> ControllerResult<web::Json<Vec<Page>>> {
68    let mut conn = pool.acquire().await?;
69    let token = authorize(
70        &mut conn,
71        Act::View,
72        auth.map(|u| u.id),
73        Res::Chapter(*chapter_id),
74    )
75    .await?;
76    let chapter_pages =
77        models::pages::get_chapters_visible_pages_exclude_main_frontpage(&mut conn, *chapter_id)
78            .await?
79            .into_iter()
80            .collect();
81    token.authorized_ok(web::Json(chapter_pages))
82}
83
84/**
85GET `/api/v0/course-material/chapters/:chapter_id/lock-preview` - Preview lock chapter
86
87Returns information about unreturned exercises in the chapter before locking.
88**/
89#[instrument(skip(pool))]
90async fn get_chapter_lock_preview(
91    chapter_id: web::Path<Uuid>,
92    pool: web::Data<PgPool>,
93    user: AuthUser,
94) -> ControllerResult<web::Json<ChapterLockPreview>> {
95    let mut conn = pool.acquire().await?;
96    let token = authorize(
97        &mut conn,
98        Act::View,
99        Some(user.id),
100        Res::Chapter(*chapter_id),
101    )
102    .await?;
103
104    let chapter = models::chapters::get_chapter(&mut conn, *chapter_id).await?;
105    let preview = models::chapters::get_chapter_lock_preview(
106        &mut conn,
107        *chapter_id,
108        user.id,
109        chapter.course_id,
110    )
111    .await?;
112
113    token.authorized_ok(web::Json(preview))
114}
115
116/**
117POST `/api/v0/course-material/chapters/:chapter_id/lock` - Complete chapter (mark as done)
118
119Completes a chapter for the authenticated user (marks it as done).
120
121Validates that:
122- Course has chapter_locking_enabled
123- Chapter is currently unlocked (student can work on it)
124- All previous chapters in the same module are completed (sequential completion)
125- Moves all exercises to manual review
126- Unlocks next chapters for the user
127**/
128#[instrument(skip(pool))]
129async fn lock_chapter(
130    chapter_id: web::Path<Uuid>,
131    pool: web::Data<PgPool>,
132    user: AuthUser,
133) -> ControllerResult<web::Json<user_chapter_locking_statuses::UserChapterLockingStatus>> {
134    let mut conn = pool.acquire().await?;
135    let chapter = models::chapters::get_chapter(&mut conn, *chapter_id).await?;
136    let token =
137        authorize_access_to_course_material(&mut conn, Some(user.id), chapter.course_id).await?;
138
139    let course = models::courses::get_course(&mut conn, chapter.course_id).await?;
140
141    if !course.chapter_locking_enabled {
142        return Err(ControllerError::new(
143            ControllerErrorType::BadRequest,
144            "Chapter locking is not enabled for this course.".to_string(),
145            None,
146        ));
147    }
148
149    let previous_chapters =
150        models::chapters::get_previous_chapters_in_module(&mut conn, *chapter_id).await?;
151
152    let mut tx = conn.begin().await?;
153
154    let current_status = user_chapter_locking_statuses::get_or_init_status(
155        &mut tx,
156        user.id,
157        *chapter_id,
158        Some(chapter.course_id),
159        Some(course.chapter_locking_enabled),
160    )
161    .await?;
162
163    match current_status {
164        None | Some(ChapterLockingStatus::NotUnlockedYet) => {
165            return Err(ControllerError::new(
166                ControllerErrorType::BadRequest,
167                "This chapter is locked. Complete previous chapters first.".to_string(),
168                None,
169            ));
170        }
171        Some(ChapterLockingStatus::CompletedAndLocked) => {
172            return Err(ControllerError::new(
173                ControllerErrorType::BadRequest,
174                "This chapter is already completed.".to_string(),
175                None,
176            ));
177        }
178        Some(ChapterLockingStatus::Unlocked) => {
179            // Continue with completion
180        }
181    }
182
183    for prev_chapter in previous_chapters {
184        let prev_status = user_chapter_locking_statuses::get_or_init_status(
185            &mut tx,
186            user.id,
187            prev_chapter.id,
188            Some(chapter.course_id),
189            Some(course.chapter_locking_enabled),
190        )
191        .await?;
192
193        match prev_status {
194            None
195            | Some(ChapterLockingStatus::Unlocked)
196            | Some(ChapterLockingStatus::NotUnlockedYet) => {
197                return Err(ControllerError::new(
198                    ControllerErrorType::BadRequest,
199                    format!(
200                        "You must complete previous chapters in order. Please complete chapter \"{}\" first.",
201                        prev_chapter.name
202                    ),
203                    None,
204                ));
205            }
206            Some(ChapterLockingStatus::CompletedAndLocked) => {
207                // Previous chapter is completed, continue
208            }
209        }
210    }
211
212    models::chapters::move_chapter_exercises_to_manual_review(
213        &mut tx,
214        *chapter_id,
215        user.id,
216        chapter.course_id,
217    )
218    .await?;
219
220    let status = user_chapter_locking_statuses::complete_chapter(
221        &mut tx,
222        user.id,
223        *chapter_id,
224        chapter.course_id,
225    )
226    .await?;
227
228    models::chapters::unlock_next_chapters_for_user(
229        &mut tx,
230        user.id,
231        *chapter_id,
232        chapter.course_id,
233    )
234    .await?;
235
236    tx.commit().await?;
237
238    token.authorized_ok(web::Json(status))
239}
240
241/**
242Add a route for each controller in this module.
243
244The name starts with an underline in order to appear before other functions in the module documentation.
245
246We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
247*/
248pub fn _add_routes(cfg: &mut ServiceConfig) {
249    cfg.route(
250        "/{chapter_id}/pages",
251        web::get().to(get_public_chapter_pages),
252    )
253    .route(
254        "/{chapter_id}/exercises",
255        web::get().to(get_chapters_exercises),
256    )
257    .route(
258        "/{chapter_id}/pages-exclude-mainfrontpage",
259        web::get().to(get_chapters_pages_without_main_frontpage),
260    )
261    .route(
262        "/{chapter_id}/lock-preview",
263        web::get().to(get_chapter_lock_preview),
264    )
265    .route("/{chapter_id}/lock", web::post().to(lock_chapter));
266}