Skip to main content

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