Skip to main content

headless_lms_server/controllers/cms/
pages.rs

1//! Controllers for requests starting with `/api/v0/cms/pages`.
2
3use models::{
4    CourseOrExamId,
5    agent_changesets::{self, AgentChangeset},
6    page_history::HistoryChangeReason,
7    pages::{
8        CmsPageSave, ContentManagementPage, PageInfo, PageNavigationInformation, PageUpdateArgs,
9        PageVisibility,
10    },
11};
12
13use crate::{
14    domain::{
15        models_requests::{self, JwtKey},
16        request_id::RequestId,
17    },
18    prelude::*,
19};
20use utoipa::OpenApi;
21
22#[derive(OpenApi)]
23#[openapi(paths(
24    get_page,
25    get_page_info,
26    update_page,
27    get_page_navigation,
28    get_exercises_with_submissions,
29    get_pending_agent_changes
30))]
31pub(crate) struct CmsPagesApiDoc;
32
33/**
34GET `/api/v0/cms/pages/:page_id` - Get a page with exercises and exercise tasks by id.
35
36Request: `GET /api/v0/cms/pages/40ca9bcf-8eaa-41ba-940e-0fd5dd0c3c02`
37*/
38#[instrument(skip(pool))]
39#[utoipa::path(
40    get,
41    path = "/{page_id}",
42    operation_id = "getCmsPage",
43    tag = "cms_pages",
44    params(
45        ("page_id" = Uuid, Path, description = "Page id")
46    ),
47    responses(
48        (status = 200, description = "CMS page with exercises and peer review data", body = ContentManagementPage)
49    )
50)]
51async fn get_page(
52    page_id: web::Path<Uuid>,
53    pool: web::Data<PgPool>,
54    user: AuthUser,
55) -> ControllerResult<web::Json<ContentManagementPage>> {
56    let mut conn = pool.acquire().await?;
57    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Page(*page_id)).await?;
58
59    let cms_page = models::pages::get_page_with_exercises(&mut conn, *page_id).await?;
60    token.authorized_ok(web::Json(cms_page))
61}
62
63/**
64GET `/api/v0/cms/pages/:page_id/info` - Get a pages's course id, course name, organization slug
65
66Request: `GET /api/v0/cms/pages/40ca9bcf-8eaa-41ba-940e-0fd5dd0c3c02/info`
67*/
68#[utoipa::path(
69    get,
70    path = "/{page_id}/info",
71    operation_id = "getCmsPageInfo",
72    tag = "cms_pages",
73    params(
74        ("page_id" = Uuid, Path, description = "Page id")
75    ),
76    responses(
77        (status = 200, description = "Page info", body = PageInfo)
78    )
79)]
80async fn get_page_info(
81    page_id: web::Path<Uuid>,
82    pool: web::Data<PgPool>,
83    user: AuthUser,
84) -> ControllerResult<web::Json<PageInfo>> {
85    let mut conn = pool.acquire().await?;
86    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Page(*page_id)).await?;
87
88    let cms_page_info = models::pages::get_page_info(&mut conn, *page_id).await?;
89    token.authorized_ok(web::Json(cms_page_info))
90}
91
92/**
93PUT `/api/v0/cms/pages/:page_id` - Update a page by id.
94
95Please note that this endpoint will change all the exercise and exercise task ids you've created. Make sure the use the updated ids from the response object.
96
97If optional property front_page_of_chapter_id is set, this page will become the front page of the specified course part.
98
99# Example: OUTDATED
100
101Request:
102
103```http
104PUT /api/v0/cms/pages/40ca9bcf-8eaa-41ba-940e-0fd5dd0c3c02 HTTP/1.1
105Content-Type: application/json
106
107{
108  "content": [{"type": "x"}],
109  "url_path": "/part-1/hello-world",
110  "title": "Hello world!",
111  "chapter_id": "2495ffa3-7ea9-4615-baa5-828023688c79"
112}
113```
114*/
115
116#[instrument(skip(pool, app_conf))]
117#[utoipa::path(
118    put,
119    path = "/{page_id}",
120    operation_id = "updateCmsPage",
121    tag = "cms_pages",
122    params(
123        ("page_id" = Uuid, Path, description = "Page id")
124    ),
125    request_body = CmsPageSave,
126    responses(
127        (status = 200, description = "Updated CMS page", body = ContentManagementPage),
128        (status = 422, description = "`stale_version`: the page changed after the editor loaded it")
129    )
130)]
131async fn update_page(
132    request_id: RequestId,
133    payload: web::Json<CmsPageSave>,
134    page_id: web::Path<Uuid>,
135    pool: web::Data<PgPool>,
136    jwt_key: web::Data<JwtKey>,
137    app_conf: web::Data<ApplicationConfiguration>,
138    user: AuthUser,
139) -> ControllerResult<web::Json<ContentManagementPage>> {
140    let mut conn = pool.acquire().await?;
141    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Page(*page_id)).await?;
142
143    let CmsPageSave {
144        page: cms_page_update,
145        expected_updated_at,
146    } = payload.0;
147    domain::authorization::ensure_user_can_add_exercise_tasks(
148        &mut conn,
149        user.id,
150        Some(*page_id),
151        &cms_page_update.exercise_tasks,
152    )
153    .await?;
154    let course_or_exam_id = models::pages::get_course_and_exam_id(&mut conn, *page_id).await?;
155    let is_exam_page = matches!(course_or_exam_id, CourseOrExamId::Exam(_));
156    let (expected_course_id, expected_exam_id) = match course_or_exam_id {
157        CourseOrExamId::Course(course_id) => (Some(course_id), None),
158        CourseOrExamId::Exam(exam_id) => (None, Some(exam_id)),
159    };
160    let saved = models::pages::update_by_id_in_parent_context(
161        &mut conn,
162        PageUpdateArgs {
163            page_id: *page_id,
164            author: models::entity_history::HistoryAuthor::user(user.id),
165            cms_page_update,
166            retain_ids: false,
167            history_change_reason: HistoryChangeReason::PageSaved,
168            is_exam_page,
169        },
170        expected_course_id,
171        expected_exam_id,
172        expected_updated_at,
173        models_requests::make_spec_fetcher(
174            app_conf.base_url.clone(),
175            request_id.0,
176            jwt_key.into_inner(),
177        ),
178        models_requests::fetch_service_info,
179    )
180    .await?;
181    token.authorized_ok(web::Json(saved))
182}
183
184/**
185POST `/api/v0/cms/pages/:page_id/exercises-with-submissions` - Given a set of exercise ids, returns
186the subset that has at least one existing submission. Used by the editor to warn the teacher before
187saving a page edit that would remove one of these exercises, since that soft-deletes it and orphans
188its submissions.
189*/
190#[instrument(skip(pool))]
191#[utoipa::path(
192    post,
193    path = "/{page_id}/exercises-with-submissions",
194    operation_id = "getExercisesWithSubmissions",
195    tag = "cms_pages",
196    params(
197        ("page_id" = Uuid, Path, description = "Page id")
198    ),
199    request_body = Vec<Uuid>,
200    responses(
201        (status = 200, description = "Exercise ids, among the given ones, that have submissions", body = Vec<Uuid>)
202    )
203)]
204async fn get_exercises_with_submissions(
205    exercise_ids: web::Json<Vec<Uuid>>,
206    page_id: web::Path<Uuid>,
207    pool: web::Data<PgPool>,
208    user: AuthUser,
209) -> ControllerResult<web::Json<Vec<Uuid>>> {
210    let mut conn = pool.acquire().await?;
211    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Page(*page_id)).await?;
212
213    let exercise_ids_with_submissions =
214        models::exercise_slide_submissions::get_exercise_ids_with_any_submissions(
215            &mut conn,
216            *page_id,
217            &exercise_ids.0,
218        )
219        .await?;
220
221    token.authorized_ok(web::Json(exercise_ids_with_submissions))
222}
223
224/**
225GET /api/v0/cms/pages/:page_id/page-navigation - tells what's the next page, previous page, and the chapter front page given a page id.
226*/
227#[instrument(skip(pool))]
228#[utoipa::path(
229    get,
230    path = "/{page_id}/page-navigation",
231    operation_id = "getCmsPageNavigation",
232    tag = "cms_pages",
233    params(
234        ("page_id" = Uuid, Path, description = "Page id")
235    ),
236    responses(
237        (status = 200, description = "Page navigation", body = PageNavigationInformation)
238    )
239)]
240async fn get_page_navigation(
241    page_id: web::Path<Uuid>,
242    pool: web::Data<PgPool>,
243) -> ControllerResult<web::Json<PageNavigationInformation>> {
244    let mut conn = pool.acquire().await?;
245    let token = skip_authorize();
246    let res =
247        models::pages::get_page_navigation_data(&mut conn, *page_id, PageVisibility::Any).await?;
248
249    token.authorized_ok(web::Json(res))
250}
251
252/**
253GET `/api/v0/cms/pages/:page_id/pending-agent-changes` - Whether the signed-in user has
254unpublished AI agent changes on the page, for the notice in the page editor. `null` if not.
255*/
256#[instrument(skip(pool))]
257#[utoipa::path(
258    get,
259    path = "/{page_id}/pending-agent-changes",
260    operation_id = "getCmsPagePendingAgentChanges",
261    tag = "cms_pages",
262    params(
263        ("page_id" = Uuid, Path, description = "Page id")
264    ),
265    responses(
266        (status = 200, description = "The open changeset changing the page, if any", body = Option<AgentChangeset>)
267    )
268)]
269async fn get_pending_agent_changes(
270    page_id: web::Path<Uuid>,
271    pool: web::Data<PgPool>,
272    user: AuthUser,
273) -> ControllerResult<web::Json<Option<AgentChangeset>>> {
274    let mut conn = pool.acquire().await?;
275    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Page(*page_id)).await?;
276    let changeset = agent_changesets::get_open_changing_page(&mut conn, user.id, *page_id).await?;
277    token.authorized_ok(web::Json(changeset))
278}
279
280/**
281Add a route for each controller in this module.
282
283The name starts with an underline in order to appear before other functions in the module documentation.
284
285We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
286*/
287pub fn _add_routes(cfg: &mut ServiceConfig) {
288    cfg.route("/{page_id}", web::get().to(get_page))
289        .route("/{page_id}/info", web::get().to(get_page_info))
290        .route(
291            "/{page_id}/page-navigation",
292            web::get().to(get_page_navigation),
293        )
294        .route("/{page_id}", web::put().to(update_page))
295        .route(
296            "/{page_id}/exercises-with-submissions",
297            web::post().to(get_exercises_with_submissions),
298        )
299        .route(
300            "/{page_id}/pending-agent-changes",
301            web::get().to(get_pending_agent_changes),
302        );
303}