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(
136        &mut conn,
137        chapter_id,
138        models::entity_history::HistoryAuthor::user(user.id),
139    )
140    .await?;
141    return token.authorized_ok(web::Json(Chapter::from_database_chapter(
142        &deleted_chapter,
143        file_store.as_ref(),
144        app_conf.as_ref(),
145    )));
146}
147
148/**
149PUT `/api/v0/main-frontend/chapters/:chapter_id` - Update chapter.
150# Example
151
152Request:
153```http
154PUT /api/v0/main-frontend/chapters/d332f3d9-39a5-4a18-80f4-251727693c37  HTTP/1.1
155Content-Type: application/json
156
157{
158    "name": "The Basics",
159    "chapter_image_url": null,
160    "chapter_number": 2,
161    "front_page_id": "0ebba931-b027-4154-8274-2afb00d79306"
162}
163
164```
165*/
166
167#[instrument(skip(payload, pool, file_store, app_conf))]
168#[utoipa::path(
169    put,
170    path = "/{chapter_id}",
171    operation_id = "updateChapter",
172    tag = "chapters",
173    params(
174        ("chapter_id" = Uuid, Path, description = "Chapter id")
175    ),
176    request_body = ChapterUpdate,
177    responses(
178        (status = 200, description = "Updated chapter", body = Chapter)
179    )
180)]
181async fn update_chapter(
182    payload: web::Json<ChapterUpdate>,
183    chapter_id: web::Path<String>,
184    pool: web::Data<PgPool>,
185    user: AuthUser,
186    file_store: web::Data<dyn FileStore>,
187    app_conf: web::Data<ApplicationConfiguration>,
188) -> ControllerResult<web::Json<Chapter>> {
189    let mut conn = pool.acquire().await?;
190    let chapter_id = Uuid::from_str(&chapter_id)?;
191    let course_id = models::chapters::get_course_id(&mut conn, chapter_id).await?;
192    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Course(course_id)).await?;
193    let course_update = payload.0;
194    let chapter =
195        models::chapters::update_chapter(&mut conn, chapter_id, course_update, user.id).await?;
196
197    let response = Chapter::from_database_chapter(&chapter, file_store.as_ref(), app_conf.as_ref());
198
199    token.authorized_ok(web::Json(response))
200}
201
202/**
203PUT `/api/v0/main-frontend/chapters/:chapter_id/image` - Sets or updates the chapter image.
204
205# Example
206
207Request:
208```http
209PUT /api/v0/main-frontend/chapters/d332f3d9-39a5-4a18-80f4-251727693c37/image HTTP/1.1
210Content-Type: multipart/form-data
211
212BINARY_DATA
213```
214*/
215
216#[instrument(skip(request, payload, pool, file_store, app_conf))]
217#[utoipa::path(
218    put,
219    path = "/{chapter_id}/image",
220    operation_id = "updateChapterImage",
221    tag = "chapters",
222    params(
223        ("chapter_id" = Uuid, Path, description = "Chapter id")
224    ),
225    request_body(content = inline(ChapterImageUploadPayload), content_type = "multipart/form-data"),
226    responses(
227        (status = 200, description = "Updated chapter image", body = Chapter)
228    )
229)]
230async fn set_chapter_image(
231    request: HttpRequest,
232    payload: Multipart,
233    chapter_id: web::Path<Uuid>,
234    pool: web::Data<PgPool>,
235    user: AuthUser,
236    file_store: web::Data<dyn FileStore>,
237    app_conf: web::Data<ApplicationConfiguration>,
238) -> ControllerResult<web::Json<Chapter>> {
239    let mut conn = pool.acquire().await?;
240    let chapter = models::chapters::get_chapter(&mut conn, *chapter_id).await?;
241    let token = authorize(
242        &mut conn,
243        Act::Edit,
244        Some(user.id),
245        Res::Course(chapter.course_id),
246    )
247    .await?;
248
249    let course = models::courses::get_course(&mut conn, chapter.course_id).await?;
250    let chapter_image = upload_file_from_cms(
251        request.headers(),
252        payload,
253        MediaOwner::Course(course.id),
254        file_store.as_ref(),
255        &mut conn,
256        user,
257    )
258    .await?
259    .to_string_lossy()
260    .to_string();
261    let updated_chapter = models::chapters::update_chapter_image_path(
262        &mut conn,
263        chapter.id,
264        Some(chapter_image),
265        user.id,
266    )
267    .await?;
268
269    // Course copies share chapter images with their source.
270    if let Some(old_image_path) = chapter.chapter_image_path
271        && !models::chapters::chapter_image_path_is_referenced(&mut conn, &old_image_path).await?
272    {
273        let file = PathBuf::from_str(&old_image_path).map_err(|original_error| {
274            ControllerError::new(
275                ControllerErrorType::InternalServerError,
276                original_error.to_string(),
277                Some(original_error.into()),
278            )
279        })?;
280        file_store.delete(&file).await.map_err(|original_error| {
281            ControllerError::new(
282                ControllerErrorType::InternalServerError,
283                original_error.to_string(),
284                Some(original_error.into()),
285            )
286        })?;
287    }
288
289    let response =
290        Chapter::from_database_chapter(&updated_chapter, file_store.as_ref(), app_conf.as_ref());
291
292    token.authorized_ok(web::Json(response))
293}
294
295/**
296DELETE `/api/v0/main-frontend/chapters/:chapter_id/image` - Removes the chapter image.
297
298# Example
299
300Request:
301```http
302DELETE /api/v0/main-frontend/chapters/d332f3d9-39a5-4a18-80f4-251727693c37/image HTTP/1.1
303```
304*/
305
306#[instrument(skip(pool, file_store))]
307#[utoipa::path(
308    delete,
309    path = "/{chapter_id}/image",
310    operation_id = "deleteChapterImage",
311    tag = "chapters",
312    params(
313        ("chapter_id" = Uuid, Path, description = "Chapter id")
314    ),
315    responses(
316        (status = 200, description = "Deleted chapter image")
317    )
318)]
319async fn remove_chapter_image(
320    chapter_id: web::Path<Uuid>,
321    pool: web::Data<PgPool>,
322    user: AuthUser,
323    file_store: web::Data<dyn FileStore>,
324) -> ControllerResult<web::Json<()>> {
325    let mut conn = pool.acquire().await?;
326    let chapter = models::chapters::get_chapter(&mut conn, *chapter_id).await?;
327    let token = authorize(
328        &mut conn,
329        Act::Edit,
330        Some(user.id),
331        Res::Course(chapter.course_id),
332    )
333    .await?;
334    if let Some(chapter_image_path) = chapter.chapter_image_path {
335        let _res =
336            models::chapters::update_chapter_image_path(&mut conn, chapter.id, None, user.id)
337                .await?;
338        // Course copies share chapter images with their source.
339        if !models::chapters::chapter_image_path_is_referenced(&mut conn, &chapter_image_path)
340            .await?
341        {
342            let file = PathBuf::from_str(&chapter_image_path).map_err(|original_error| {
343                controller_err!(
344                    InternalServerError,
345                    original_error.to_string(),
346                    original_error
347                )
348            })?;
349            file_store.delete(&file).await.map_err(|original_error| {
350                controller_err!(
351                    InternalServerError,
352                    original_error.to_string(),
353                    original_error
354                )
355            })?;
356        }
357    }
358    token.authorized_ok(web::Json(()))
359}
360
361/**
362GET `/api/v0/main-frontend/chapters/{course_id}/all-chapters-for-course - Gets all chapters with a course_id
363*/
364#[utoipa::path(
365    get,
366    path = "/{course_id}/all-chapters-for-course",
367    operation_id = "getCourseChapters",
368    tag = "chapters",
369    params(
370        ("course_id" = Uuid, Path, description = "Course id")
371    ),
372    responses(
373        (status = 200, description = "Course chapters", body = Vec<DatabaseChapter>)
374    )
375)]
376async fn get_all_chapters_by_course_id(
377    course_id: web::Path<Uuid>,
378    pool: web::Data<PgPool>,
379    user: AuthUser,
380) -> ControllerResult<web::Json<Vec<DatabaseChapter>>> {
381    let mut conn = pool.acquire().await?;
382    let token = authorize(&mut conn, Act::View, Some(user.id), Res::Course(*course_id)).await?;
383
384    let mut chapters = models::chapters::get_course_chapters(&mut conn, *course_id).await?;
385
386    chapters.sort_by_key(|a| a.chapter_number);
387
388    token.authorized_ok(web::Json(chapters))
389}
390
391/**
392Add a route for each controller in this module.
393
394The name starts with an underline in order to appear before other functions in the module documentation.
395
396We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
397*/
398pub fn _add_routes(cfg: &mut ServiceConfig) {
399    cfg.route("", web::post().to(post_new_chapter))
400        .route("/{chapter_id}", web::delete().to(delete_chapter))
401        .route("/{chapter_id}", web::put().to(update_chapter))
402        .route("/{chapter_id}/image", web::put().to(set_chapter_image))
403        .route(
404            "/{chapter_id}/image",
405            web::delete().to(remove_chapter_image),
406        )
407        .route(
408            "/{course_id}/all-chapters-for-course",
409            web::get().to(get_all_chapters_by_course_id),
410        );
411}