Skip to main content

headless_lms_server/controllers/course_material/
pages.rs

1//! Controllers for requests starting with `/api/v0/course-material/pages`.
2
3use std::collections::HashSet;
4
5use crate::{
6    domain::authorization::{
7        AuthorizationToken, authorize_access_to_course_material, skip_authorize,
8    },
9    prelude::*,
10};
11use models::pages::{
12    IsChapterFrontPage, Page, PageChapterAndCourseInformation, PageNavigationInformation,
13    PageVisibility,
14};
15use utoipa::OpenApi;
16
17#[derive(OpenApi)]
18#[openapi(paths(
19    get_by_exam_id,
20    get_chapter_front_page,
21    get_page_navigation,
22    get_chapter_and_course_information,
23    get_url_path,
24    is_chapter_front_page
25))]
26pub(crate) struct CourseMaterialPagesApiDoc;
27
28fn page_not_found() -> ControllerError {
29    controller_err!(NotFound, "Page not found".to_string())
30}
31
32async fn authorize_page_parent_access(
33    conn: &mut PgConnection,
34    user_id: Option<Uuid>,
35    page: &Page,
36) -> Result<(AuthorizationToken, PageVisibility), ControllerError> {
37    if page.deleted_at.is_some() {
38        return Err(page_not_found());
39    }
40    if let Some(course_id) = page.course_id {
41        let token = authorize_access_to_course_material(conn, user_id, course_id).await?;
42        let can_view_hidden_pages = if let Some(user_id) = user_id {
43            authorize(
44                conn,
45                Act::ViewMaterial,
46                Some(user_id),
47                Res::Course(course_id),
48            )
49            .await
50            .is_ok()
51        } else {
52            false
53        };
54        if page.hidden && !can_view_hidden_pages {
55            return Err(page_not_found());
56        }
57        let visibility = if can_view_hidden_pages {
58            PageVisibility::Any
59        } else {
60            PageVisibility::Public
61        };
62        Ok((token, visibility))
63    } else if let Some(exam_id) = page.exam_id {
64        let user_id = user_id.ok_or_else(|| {
65            controller_err!(
66                Unauthorized,
67                "Authentication required for exam page".to_string()
68            )
69        })?;
70        let token = authorize(conn, Act::View, Some(user_id), Res::Exam(exam_id)).await?;
71        Ok((token, PageVisibility::Any))
72    } else {
73        Err(page_not_found())
74    }
75}
76
77async fn filter_navigation_by_visibility(
78    conn: &mut PgConnection,
79    mut navigation: PageNavigationInformation,
80    visibility: PageVisibility,
81) -> Result<PageNavigationInformation, ControllerError> {
82    let Some(chapter_front) = navigation.chapter_front_page.as_ref() else {
83        return Ok(navigation);
84    };
85    let visible_page_ids =
86        models::pages::get_by_ids_and_visibility(conn, &[chapter_front.page_id], visibility)
87            .await?
88            .into_iter()
89            .map(|page| page.id)
90            .collect::<HashSet<_>>();
91
92    if !visible_page_ids.contains(&chapter_front.page_id) {
93        navigation.chapter_front_page = None;
94    }
95
96    Ok(navigation)
97}
98
99/**
100GET /api/v0/course-material/pages/exam/{page_id}
101*/
102#[utoipa::path(
103    get,
104    path = "/exam/{page_id}",
105    operation_id = "getCourseMaterialPageByExamId",
106    tag = "course-material-pages",
107    params(
108        ("page_id" = Uuid, Path, description = "Exam id")
109    ),
110    responses(
111        (status = 200, description = "Exam page", body = Page)
112    )
113)]
114#[instrument(skip(pool))]
115async fn get_by_exam_id(
116    exam_id: web::Path<Uuid>,
117    pool: web::Data<PgPool>,
118    auth: AuthUser,
119) -> ControllerResult<web::Json<Page>> {
120    let mut conn = pool.acquire().await?;
121    let token = authorize(&mut conn, Act::View, Some(auth.id), Res::Exam(*exam_id)).await?;
122    let page = models::pages::get_by_exam_id(&mut conn, *exam_id).await?;
123    let page = models::pages::filter_course_material_page(&mut conn, Some(auth.id), page).await?;
124    token.authorized_ok(web::Json(page))
125}
126
127/**
128GET /api/v0/course-material/page/{page_id}
129*/
130#[utoipa::path(
131    get,
132    path = "/{current_page_id}/chapter-front-page",
133    operation_id = "getCourseMaterialChapterFrontPage",
134    tag = "course-material-pages",
135    params(
136        ("current_page_id" = Uuid, Path, description = "Current page id")
137    ),
138    responses(
139        (status = 200, description = "Chapter front page", body = Option<Page>)
140    )
141)]
142#[instrument(skip(pool))]
143async fn get_chapter_front_page(
144    page_id: web::Path<Uuid>,
145    pool: web::Data<PgPool>,
146    auth: Option<AuthUser>,
147) -> ControllerResult<web::Json<Option<Page>>> {
148    let mut conn = pool.acquire().await?;
149    let user_id = auth.map(|u| u.id);
150    let current_page = models::pages::get_page(&mut conn, *page_id).await?;
151    let (token, visibility) =
152        authorize_page_parent_access(&mut conn, user_id, &current_page).await?;
153    let chapter_front_page = match current_page.chapter_id {
154        Some(chapter_id) => {
155            models::pages::get_front_page_by_chapter_id(&mut conn, chapter_id).await?
156        }
157        None => None,
158    };
159    let chapter_front_page = match chapter_front_page {
160        Some(page) => {
161            let visible_pages =
162                models::pages::get_by_ids_and_visibility(&mut conn, &[page.id], visibility).await?;
163            if visible_pages.is_empty() {
164                None
165            } else {
166                Some(models::pages::filter_course_material_page(&mut conn, user_id, page).await?)
167            }
168        }
169        None => None,
170    };
171    token.authorized_ok(web::Json(chapter_front_page))
172}
173
174/**
175GET /api/v0/course-material/pages/:page_id/page-navigation - tells what's the next page, previous page, and the chapter front page given a page id.
176*/
177#[utoipa::path(
178    get,
179    path = "/{current_page_id}/page-navigation",
180    operation_id = "getCourseMaterialPageNavigation",
181    tag = "course-material-pages",
182    params(
183        ("current_page_id" = Uuid, Path, description = "Current page id")
184    ),
185    responses(
186        (status = 200, description = "Page navigation information", body = PageNavigationInformation)
187    )
188)]
189#[instrument(skip(pool))]
190async fn get_page_navigation(
191    page_id: web::Path<Uuid>,
192    pool: web::Data<PgPool>,
193    auth: Option<AuthUser>,
194) -> ControllerResult<web::Json<PageNavigationInformation>> {
195    let mut conn = pool.acquire().await?;
196    let user_id = auth.map(|u| u.id);
197    let current_page = models::pages::get_page(&mut conn, *page_id).await?;
198    let (token, visibility) =
199        authorize_page_parent_access(&mut conn, user_id, &current_page).await?;
200    let res = models::pages::get_page_navigation_data(&mut conn, *page_id, visibility).await?;
201    let res = filter_navigation_by_visibility(&mut conn, res, visibility).await?;
202
203    token.authorized_ok(web::Json(res))
204}
205
206/**
207 GET /api/v0/course-material/pages/:page_id/chapter-and-course-information - gives the page's chapter and course information -- useful for the breadcrumbs
208*/
209#[utoipa::path(
210    get,
211    path = "/{current_page_id}/chapter-and-course-information",
212    operation_id = "getCourseMaterialPageChapterAndCourseInformation",
213    tag = "course-material-pages",
214    params(
215        ("current_page_id" = Uuid, Path, description = "Current page id")
216    ),
217    responses(
218        (status = 200, description = "Page chapter and course information", body = PageChapterAndCourseInformation)
219    )
220)]
221#[instrument(skip(pool))]
222async fn get_chapter_and_course_information(
223    page_id: web::Path<Uuid>,
224    pool: web::Data<PgPool>,
225) -> ControllerResult<web::Json<PageChapterAndCourseInformation>> {
226    let mut conn = pool.acquire().await?;
227    let res = models::pages::get_page_chapter_and_course_information(&mut conn, *page_id).await?;
228
229    let token = skip_authorize();
230    token.authorized_ok(web::Json(res))
231}
232
233/**
234 GET /api/v0/course-material/pages/:page_id/url-path - returns the page's URL path.
235 # Example
236 ```json
237 "chapter-1/page-2"
238 ```
239*/
240#[utoipa::path(
241    get,
242    path = "/{current_page_id}/url-path",
243    operation_id = "getCourseMaterialPageUrlPath",
244    tag = "course-material-pages",
245    params(
246        ("current_page_id" = Uuid, Path, description = "Current page id")
247    ),
248    responses(
249        (status = 200, description = "Page URL path", body = String)
250    )
251)]
252#[instrument(skip(pool))]
253async fn get_url_path(
254    page_id: web::Path<Uuid>,
255    pool: web::Data<PgPool>,
256    auth: Option<AuthUser>,
257) -> ControllerResult<String> {
258    let mut conn = pool.acquire().await?;
259    let page = models::pages::get_page(&mut conn, *page_id).await?;
260    let user_id = auth.map(|u| u.id);
261
262    let (token, _) = authorize_page_parent_access(&mut conn, user_id, &page).await?;
263    token.authorized_ok(page.url_path)
264}
265
266#[utoipa::path(
267    get,
268    path = "/{current_page_id}/is-chapter-front-page",
269    operation_id = "getCourseMaterialIsPageChapterFrontPage",
270    tag = "course-material-pages",
271    params(
272        ("current_page_id" = Uuid, Path, description = "Current page id")
273    ),
274    responses(
275        (status = 200, description = "Whether page is chapter front page", body = IsChapterFrontPage)
276    )
277)]
278#[instrument(skip(pool))]
279async fn is_chapter_front_page(
280    page_id: web::Path<Uuid>,
281    pool: web::Data<PgPool>,
282) -> ControllerResult<web::Json<IsChapterFrontPage>> {
283    let mut conn = pool.acquire().await?;
284    let is_chapter_front_page = models::pages::is_chapter_front_page(&mut conn, *page_id).await?;
285    let token = skip_authorize();
286    token.authorized_ok(web::Json(is_chapter_front_page))
287}
288
289pub fn _add_routes(cfg: &mut ServiceConfig) {
290    cfg.route("/exam/{page_id}", web::get().to(get_by_exam_id))
291        .route(
292            "/{current_page_id}/chapter-front-page",
293            web::get().to(get_chapter_front_page),
294        )
295        .route("/{current_page_id}/url-path", web::get().to(get_url_path))
296        .route(
297            "/{current_page_id}/chapter-and-course-information",
298            web::get().to(get_chapter_and_course_information),
299        )
300        .route(
301            "/{current_page_id}/is-chapter-front-page",
302            web::get().to(is_chapter_front_page),
303        )
304        .route(
305            "/{current_page_id}/page-navigation",
306            web::get().to(get_page_navigation),
307        );
308}