Skip to main content

headless_lms_server/controllers/main_frontend/
chapters.rs

1//! Controllers for requests starting with `/api/v0/main-frontend/chapters`.
2
3use std::{path::PathBuf, str::FromStr, sync::Arc};
4
5use headless_lms_models::chapters::DatabaseChapter;
6use models::chapters::{Chapter, ChapterUpdate, NewChapter};
7use utoipa::{OpenApi, ToSchema};
8
9use crate::{
10    domain::{
11        models_requests::{self, JwtKey},
12        request_id::RequestId,
13    },
14    prelude::*,
15};
16
17#[derive(OpenApi)]
18#[openapi(paths(
19    post_new_chapter,
20    delete_chapter,
21    update_chapter,
22    set_chapter_image,
23    remove_chapter_image,
24    get_all_chapters_by_course_id
25))]
26pub(crate) struct MainFrontendChaptersApiDoc;
27
28#[allow(dead_code)]
29#[derive(Debug, ToSchema)]
30struct ChapterImageUploadPayload {
31    #[schema(content_media_type = "application/octet-stream")]
32    file: Vec<u8>,
33}
34
35/**
36POST `/api/v0/main-frontend/chapters` - Create a new course part.
37# Example
38
39Request:
40```http
41POST /api/v0/main-frontend/chapters HTTP/1.1
42Content-Type: application/json
43
44{
45    "name": "The Basics",
46    "course_id": "d86cf910-4d26-40e9-8c9c-1cc35294fdbb",
47    "chapter_number": 1,
48    "front_page_id": null
49}
50```
51*/
52
53#[instrument(skip(pool, file_store, app_conf))]
54#[utoipa::path(
55    post,
56    path = "",
57    operation_id = "createChapter",
58    tag = "chapters",
59    request_body = NewChapter,
60    responses(
61        (status = 200, description = "Created chapter", body = Chapter)
62    )
63)]
64async fn post_new_chapter(
65    request_id: RequestId,
66    pool: web::Data<PgPool>,
67    payload: web::Json<NewChapter>,
68    user: AuthUser,
69    file_store: web::Data<dyn FileStore>,
70    app_conf: web::Data<ApplicationConfiguration>,
71    jwt_key: web::Data<JwtKey>,
72) -> ControllerResult<web::Json<Chapter>> {
73    let mut conn = pool.acquire().await?;
74    let token = authorize(
75        &mut conn,
76        Act::Edit,
77        Some(user.id),
78        Res::Course(payload.course_id),
79    )
80    .await?;
81    let new_chapter = payload.0;
82    let (database_chapter, ..) = models::library::content_management::create_new_chapter(
83        &mut conn,
84        PKeyPolicy::Generate,
85        &new_chapter,
86        user.id,
87        models_requests::make_spec_fetcher(
88            app_conf.base_url.clone(),
89            request_id.0,
90            Arc::clone(&jwt_key),
91        ),
92        models_requests::fetch_service_info,
93    )
94    .await?;
95    return token.authorized_ok(web::Json(Chapter::from_database_chapter(
96        &database_chapter,
97        file_store.as_ref(),
98        app_conf.as_ref(),
99    )));
100}
101
102/**
103DELETE `/api/v0/main-frontend/chapters/:chapter_id` - Delete a chapter.
104*/
105
106#[instrument(skip(pool, file_store, app_conf))]
107#[utoipa::path(
108    delete,
109    path = "/{chapter_id}",
110    operation_id = "deleteChapter",
111    tag = "chapters",
112    params(
113        ("chapter_id" = Uuid, Path, description = "Chapter id")
114    ),
115    responses(
116        (status = 200, description = "Deleted chapter", body = Chapter)
117    )
118)]
119async fn delete_chapter(
120    chapter_id: web::Path<String>,
121    pool: web::Data<PgPool>,
122    user: AuthUser,
123    file_store: web::Data<dyn FileStore>,
124    app_conf: web::Data<ApplicationConfiguration>,
125) -> ControllerResult<web::Json<Chapter>> {
126    let mut conn = pool.acquire().await?;
127    let chapter_id = Uuid::from_str(&chapter_id)?;
128    let token = authorize(
129        &mut conn,
130        Act::Edit,
131        Some(user.id),
132        Res::Chapter(chapter_id),
133    )
134    .await?;
135    let deleted_chapter = models::chapters::delete_chapter(&mut conn, chapter_id).await?;
136    return token.authorized_ok(web::Json(Chapter::from_database_chapter(
137        &deleted_chapter,
138        file_store.as_ref(),
139        app_conf.as_ref(),
140    )));
141}
142
143/**
144PUT `/api/v0/main-frontend/chapters/:chapter_id` - Update chapter.
145# Example
146
147Request:
148```http
149PUT /api/v0/main-frontend/chapters/d332f3d9-39a5-4a18-80f4-251727693c37  HTTP/1.1
150Content-Type: application/json
151
152{
153    "name": "The Basics",
154    "chapter_image_url": null,
155    "chapter_number": 2,
156    "front_page_id": "0ebba931-b027-4154-8274-2afb00d79306"
157}
158
159```
160*/
161
162#[instrument(skip(payload, pool, file_store, app_conf))]
163#[utoipa::path(
164    put,
165    path = "/{chapter_id}",
166    operation_id = "updateChapter",
167    tag = "chapters",
168    params(
169        ("chapter_id" = Uuid, Path, description = "Chapter id")
170    ),
171    request_body = ChapterUpdate,
172    responses(
173        (status = 200, description = "Updated chapter", body = Chapter)
174    )
175)]
176async fn update_chapter(
177    payload: web::Json<ChapterUpdate>,
178    chapter_id: web::Path<String>,
179    pool: web::Data<PgPool>,
180    user: AuthUser,
181    file_store: web::Data<dyn FileStore>,
182    app_conf: web::Data<ApplicationConfiguration>,
183) -> ControllerResult<web::Json<Chapter>> {
184    let mut conn = pool.acquire().await?;
185    let chapter_id = Uuid::from_str(&chapter_id)?;
186    let course_id = models::chapters::get_course_id(&mut conn, chapter_id).await?;
187    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Course(course_id)).await?;
188    let course_update = payload.0;
189    let chapter = models::chapters::update_chapter(&mut conn, chapter_id, course_update).await?;
190
191    let response = Chapter::from_database_chapter(&chapter, file_store.as_ref(), app_conf.as_ref());
192
193    token.authorized_ok(web::Json(response))
194}
195
196/**
197PUT `/api/v0/main-frontend/chapters/:chapter_id/image` - Sets or updates the chapter image.
198
199# Example
200
201Request:
202```http
203PUT /api/v0/main-frontend/chapters/d332f3d9-39a5-4a18-80f4-251727693c37/image HTTP/1.1
204Content-Type: multipart/form-data
205
206BINARY_DATA
207```
208*/
209
210#[instrument(skip(request, payload, pool, file_store, app_conf))]
211#[utoipa::path(
212    put,
213    path = "/{chapter_id}/image",
214    operation_id = "updateChapterImage",
215    tag = "chapters",
216    params(
217        ("chapter_id" = Uuid, Path, description = "Chapter id")
218    ),
219    request_body(content = inline(ChapterImageUploadPayload), content_type = "multipart/form-data"),
220    responses(
221        (status = 200, description = "Updated chapter image", body = Chapter)
222    )
223)]
224async fn set_chapter_image(
225    request: HttpRequest,
226    payload: Multipart,
227    chapter_id: web::Path<Uuid>,
228    pool: web::Data<PgPool>,
229    user: AuthUser,
230    file_store: web::Data<dyn FileStore>,
231    app_conf: web::Data<ApplicationConfiguration>,
232) -> ControllerResult<web::Json<Chapter>> {
233    let mut conn = pool.acquire().await?;
234    let chapter = models::chapters::get_chapter(&mut conn, *chapter_id).await?;
235    let token = authorize(
236        &mut conn,
237        Act::Edit,
238        Some(user.id),
239        Res::Course(chapter.course_id),
240    )
241    .await?;
242
243    let course = models::courses::get_course(&mut conn, chapter.course_id).await?;
244    let chapter_image = upload_file_from_cms(
245        request.headers(),
246        payload,
247        StoreKind::Course(course.id),
248        file_store.as_ref(),
249        &mut conn,
250        user,
251    )
252    .await?
253    .to_string_lossy()
254    .to_string();
255    let updated_chapter =
256        models::chapters::update_chapter_image_path(&mut conn, chapter.id, Some(chapter_image))
257            .await?;
258
259    // Course copies share chapter images with their source.
260    if let Some(old_image_path) = chapter.chapter_image_path
261        && !models::chapters::chapter_image_path_is_referenced(&mut conn, &old_image_path).await?
262    {
263        let file = PathBuf::from_str(&old_image_path).map_err(|original_error| {
264            ControllerError::new(
265                ControllerErrorType::InternalServerError,
266                original_error.to_string(),
267                Some(original_error.into()),
268            )
269        })?;
270        file_store.delete(&file).await.map_err(|original_error| {
271            ControllerError::new(
272                ControllerErrorType::InternalServerError,
273                original_error.to_string(),
274                Some(original_error.into()),
275            )
276        })?;
277    }
278
279    let response =
280        Chapter::from_database_chapter(&updated_chapter, file_store.as_ref(), app_conf.as_ref());
281
282    token.authorized_ok(web::Json(response))
283}
284
285/**
286DELETE `/api/v0/main-frontend/chapters/:chapter_id/image` - Removes the chapter image.
287
288# Example
289
290Request:
291```http
292DELETE /api/v0/main-frontend/chapters/d332f3d9-39a5-4a18-80f4-251727693c37/image HTTP/1.1
293```
294*/
295
296#[instrument(skip(pool, file_store))]
297#[utoipa::path(
298    delete,
299    path = "/{chapter_id}/image",
300    operation_id = "deleteChapterImage",
301    tag = "chapters",
302    params(
303        ("chapter_id" = Uuid, Path, description = "Chapter id")
304    ),
305    responses(
306        (status = 200, description = "Deleted chapter image")
307    )
308)]
309async fn remove_chapter_image(
310    chapter_id: web::Path<Uuid>,
311    pool: web::Data<PgPool>,
312    user: AuthUser,
313    file_store: web::Data<dyn FileStore>,
314) -> ControllerResult<web::Json<()>> {
315    let mut conn = pool.acquire().await?;
316    let chapter = models::chapters::get_chapter(&mut conn, *chapter_id).await?;
317    let token = authorize(
318        &mut conn,
319        Act::Edit,
320        Some(user.id),
321        Res::Course(chapter.course_id),
322    )
323    .await?;
324    if let Some(chapter_image_path) = chapter.chapter_image_path {
325        let _res = models::chapters::update_chapter_image_path(&mut conn, chapter.id, None).await?;
326        // Course copies share chapter images with their source.
327        if !models::chapters::chapter_image_path_is_referenced(&mut conn, &chapter_image_path)
328            .await?
329        {
330            let file = PathBuf::from_str(&chapter_image_path).map_err(|original_error| {
331                controller_err!(
332                    InternalServerError,
333                    original_error.to_string(),
334                    original_error
335                )
336            })?;
337            file_store.delete(&file).await.map_err(|original_error| {
338                controller_err!(
339                    InternalServerError,
340                    original_error.to_string(),
341                    original_error
342                )
343            })?;
344        }
345    }
346    token.authorized_ok(web::Json(()))
347}
348
349/**
350GET `/api/v0/main-frontend/chapters/{course_id}/all-chapters-for-course - Gets all chapters with a course_id
351*/
352#[utoipa::path(
353    get,
354    path = "/{course_id}/all-chapters-for-course",
355    operation_id = "getCourseChapters",
356    tag = "chapters",
357    params(
358        ("course_id" = Uuid, Path, description = "Course id")
359    ),
360    responses(
361        (status = 200, description = "Course chapters", body = Vec<DatabaseChapter>)
362    )
363)]
364async fn get_all_chapters_by_course_id(
365    course_id: web::Path<Uuid>,
366    pool: web::Data<PgPool>,
367    user: AuthUser,
368) -> ControllerResult<web::Json<Vec<DatabaseChapter>>> {
369    let mut conn = pool.acquire().await?;
370    let token = authorize(&mut conn, Act::View, Some(user.id), Res::Course(*course_id)).await?;
371
372    let mut chapters = models::chapters::get_course_chapters(&mut conn, *course_id).await?;
373
374    chapters.sort_by_key(|a| a.chapter_number);
375
376    token.authorized_ok(web::Json(chapters))
377}
378
379/**
380Add a route for each controller in this module.
381
382The name starts with an underline in order to appear before other functions in the module documentation.
383
384We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
385*/
386pub fn _add_routes(cfg: &mut ServiceConfig) {
387    cfg.route("", web::post().to(post_new_chapter))
388        .route("/{chapter_id}", web::delete().to(delete_chapter))
389        .route("/{chapter_id}", web::put().to(update_chapter))
390        .route("/{chapter_id}/image", web::put().to(set_chapter_image))
391        .route(
392            "/{chapter_id}/image",
393            web::delete().to(remove_chapter_image),
394        )
395        .route(
396            "/{course_id}/all-chapters-for-course",
397            web::get().to(get_all_chapters_by_course_id),
398        );
399}