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