Skip to main content

headless_lms_server/controllers/cms/
courses.rs

1//! Controllers for requests starting with `/api/v0/cms/courses`.
2
3use crate::prelude::*;
4use utoipa::OpenApi;
5
6use headless_lms_models::chatbot_configurations::ChatbotConfiguration;
7use models::{
8    course_instances::CourseInstance,
9    courses::Course,
10    pages::{Page, PageVisibility},
11    partner_block::PartnersBlock,
12    peer_or_self_review_configs::{
13        self, CmsPeerOrSelfReviewConfigurationSave, VersionedCmsPeerOrSelfReviewConfiguration,
14    },
15    peer_or_self_review_questions::normalize_cms_peer_or_self_review_questions,
16};
17
18use crate::prelude::models::course_modules::CourseModule;
19use models::research_forms::{NewResearchForm, ResearchForm};
20
21#[derive(OpenApi)]
22#[openapi(paths(
23    add_media,
24    get_course_by_id,
25    get_course_default_peer_or_self_review_configuration,
26    put_course_default_peer_or_self_review_configuration,
27    get_all_pages,
28    upsert_course_research_form,
29    get_research_form_with_course_id,
30    get_course_modules,
31    get_course_instances,
32    post_partners_block,
33    get_partners_block,
34    delete_partners_block,
35    get_course_nondefault_chatbot_configurations
36))]
37pub(crate) struct CmsCoursesApiDoc;
38
39/**
40GET /api/v0/cms/courses/:course_id - Get the course.
41*/
42#[instrument(skip(pool))]
43#[utoipa::path(
44    get,
45    path = "/{course_id}",
46    operation_id = "getCmsCourse",
47    tag = "cms_courses",
48    params(
49        ("course_id" = Uuid, Path, description = "Course id")
50    ),
51    responses(
52        (status = 200, description = "Course", body = Course)
53    )
54)]
55async fn get_course_by_id(
56    path: web::Path<Uuid>,
57    pool: web::Data<PgPool>,
58    user: AuthUser,
59) -> ControllerResult<web::Json<Course>> {
60    let course_id = path.into_inner();
61    let mut conn = pool.acquire().await?;
62    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?;
63    let course = models::courses::get_course(&mut conn, course_id).await?;
64    token.authorized_ok(web::Json(course))
65}
66
67/**
68POST `/api/v0/cms/courses/:course_id/upload` - Uploads a media (image, audio, file) for the course from Gutenberg page edit.
69
70Put the the contents of the media in a form and add a content type header multipart/form-data.
71# Example
72
73Request:
74```http
75POST /api/v0/cms/pages/d86cf910-4d26-40e9-8c9c-1cc35294fdbb/upload HTTP/1.1
76Content-Type: multipart/form-data
77
78BINARY_DATA
79```
80*/
81
82#[utoipa::path(
83    post,
84    path = "/{course_id}/upload",
85    operation_id = "uploadCmsCourseMedia",
86    tag = "cms_courses",
87    params(
88        ("course_id" = Uuid, Path, description = "Course id")
89    ),
90    request_body(
91        content = String,
92        content_type = "multipart/form-data"
93    ),
94    responses(
95        (status = 200, description = "Uploaded media result", body = UploadResult)
96    )
97)]
98#[instrument(skip(payload, request, pool, file_store, app_conf))]
99async fn add_media(
100    course_id: web::Path<Uuid>,
101    payload: Multipart,
102    request: HttpRequest,
103    pool: web::Data<PgPool>,
104    user: AuthUser,
105    file_store: web::Data<dyn FileStore>,
106    app_conf: web::Data<ApplicationConfiguration>,
107) -> ControllerResult<web::Json<UploadResult>> {
108    let mut conn = pool.acquire().await?;
109    let course = models::courses::get_course(&mut conn, *course_id).await?;
110    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Course(course.id)).await?;
111
112    let media_path = upload_file_from_cms(
113        request.headers(),
114        payload,
115        MediaOwner::Course(course.id),
116        file_store.as_ref(),
117        &mut conn,
118        user,
119    )
120    .await?;
121    let download_url = file_store.get_download_url(media_path.as_path(), app_conf.as_ref());
122
123    token.authorized_ok(web::Json(UploadResult { url: download_url }))
124}
125
126#[instrument(skip(pool))]
127#[utoipa::path(
128    get,
129    path = "/{course_id}/default-peer-review",
130    operation_id = "getCmsCourseDefaultPeerReview",
131    tag = "cms_courses",
132    params(
133        ("course_id" = Uuid, Path, description = "Course id")
134    ),
135    responses(
136        (status = 200, description = "Default peer review configuration", body = VersionedCmsPeerOrSelfReviewConfiguration)
137    )
138)]
139async fn get_course_default_peer_or_self_review_configuration(
140    course_id: web::Path<Uuid>,
141    user: AuthUser,
142    pool: web::Data<PgPool>,
143) -> ControllerResult<web::Json<VersionedCmsPeerOrSelfReviewConfiguration>> {
144    let mut conn = pool.acquire().await?;
145    let token = authorize(
146        &mut conn,
147        Act::Teach,
148        Some(user.id),
149        Res::Course(*course_id),
150    )
151    .await?;
152
153    let configuration = peer_or_self_review_configs::get_course_default_versioned_cms_peer_review(
154        &mut conn, *course_id,
155    )
156    .await?;
157
158    token.authorized_ok(web::Json(configuration))
159}
160
161#[instrument(skip(pool))]
162#[utoipa::path(
163    put,
164    path = "/{course_id}/default-peer-review",
165    operation_id = "updateCmsCourseDefaultPeerReview",
166    tag = "cms_courses",
167    params(
168        ("course_id" = Uuid, Path, description = "Course id")
169    ),
170    request_body = CmsPeerOrSelfReviewConfigurationSave,
171    responses(
172        (status = 200, description = "Updated default peer review configuration", body = VersionedCmsPeerOrSelfReviewConfiguration),
173        (status = 422, description = "`stale_version`: the configuration changed after the editor loaded it")
174    )
175)]
176async fn put_course_default_peer_or_self_review_configuration(
177    course_id: web::Path<Uuid>,
178    user: AuthUser,
179    pool: web::Data<PgPool>,
180    payload: web::Json<CmsPeerOrSelfReviewConfigurationSave>,
181) -> ControllerResult<web::Json<VersionedCmsPeerOrSelfReviewConfiguration>> {
182    let mut conn = pool.acquire().await?;
183    let token = authorize(
184        &mut conn,
185        Act::Teach,
186        Some(user.id),
187        Res::Course(*course_id),
188    )
189    .await?;
190    let CmsPeerOrSelfReviewConfigurationSave {
191        configuration: mut config,
192        expected_updated_at,
193    } = payload.0;
194    normalize_cms_peer_or_self_review_questions(&mut config.peer_or_self_review_questions);
195    let cms_peer_or_self_review_configuration = models::entity_history::save_recording(
196        &mut conn,
197        models::library::entity_snapshots::ContentEntityType::CourseDefaultReviewConfig,
198        config.peer_or_self_review_config.id,
199        models::entity_history::HistoryAuthor::user(user.id),
200        async |tx| {
201            peer_or_self_review_configs::upsert_for_course_id(
202                tx,
203                *course_id,
204                &config,
205                expected_updated_at,
206            )
207            .await
208        },
209    )
210    .await?;
211    token.authorized_ok(web::Json(cms_peer_or_self_review_configuration))
212}
213
214/**
215GET `/api/v0/cms/courses/:course_id/pages` - Gets all pages for a course.
216*/
217#[instrument(skip(pool))]
218#[utoipa::path(
219    get,
220    path = "/{course_id}/pages",
221    operation_id = "getCmsCoursePages",
222    tag = "cms_courses",
223    params(
224        ("course_id" = Uuid, Path, description = "Course id")
225    ),
226    responses(
227        (status = 200, description = "Pages for course", body = Vec<Page>)
228    )
229)]
230async fn get_all_pages(
231    course_id: web::Path<Uuid>,
232    pool: web::Data<PgPool>,
233    user: AuthUser,
234) -> ControllerResult<web::Json<Vec<Page>>> {
235    let mut conn = pool.acquire().await?;
236    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Course(*course_id)).await?;
237
238    let res = models::pages::get_all_by_course_id_and_visibility(
239        &mut conn,
240        *course_id,
241        PageVisibility::Any,
242    )
243    .await?;
244
245    token.authorized_ok(web::Json(res))
246}
247
248/**
249PUT `/api/v0/cms/courses/:course_id/research-consent-form` - Saves the course's research form and syncs its questions from the question blocks.
250*/
251
252#[instrument(skip(pool, payload))]
253#[utoipa::path(
254    put,
255    path = "/{course_id}/research-consent-form",
256    operation_id = "upsertCmsCourseResearchForm",
257    tag = "cms_courses",
258    params(
259        ("course_id" = Uuid, Path, description = "Course id")
260    ),
261    request_body = NewResearchForm,
262    responses(
263        (status = 200, description = "Research form", body = ResearchForm)
264    )
265)]
266async fn upsert_course_research_form(
267    payload: web::Json<NewResearchForm>,
268    pool: web::Data<PgPool>,
269    course_id: web::Path<Uuid>,
270    user: AuthUser,
271) -> ControllerResult<web::Json<ResearchForm>> {
272    let mut conn = pool.acquire().await?;
273
274    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::GlobalPermissions).await?;
275    let res = models::research_forms::save_research_form(
276        &mut conn,
277        *course_id,
278        payload.into_inner().content,
279    )
280    .await?;
281
282    token.authorized_ok(web::Json(res))
283}
284
285/**
286GET `/api/v0/cms/courses/:course_id/research-consent-form` - Fetches courses research form with course id.
287*/
288#[instrument(skip(pool))]
289#[utoipa::path(
290    get,
291    path = "/{course_id}/research-consent-form",
292    operation_id = "getCmsCourseResearchForm",
293    tag = "cms_courses",
294    params(
295        ("course_id" = Uuid, Path, description = "Course id")
296    ),
297    responses(
298        (status = 200, description = "Research form", body = Option<ResearchForm>)
299    )
300)]
301async fn get_research_form_with_course_id(
302    course_id: web::Path<Uuid>,
303    user: AuthUser,
304    pool: web::Data<PgPool>,
305) -> ControllerResult<web::Json<Option<ResearchForm>>> {
306    let mut conn = pool.acquire().await?;
307
308    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::GlobalPermissions).await?;
309    let res = models::research_forms::get_research_form_with_course_id(&mut conn, *course_id)
310        .await
311        .optional()?;
312
313    token.authorized_ok(web::Json(res))
314}
315
316/**
317GET `/api/v0/cms/courses/:course_id/modules`
318Returns modules in the course.
319*/
320#[instrument(skip(pool))]
321#[utoipa::path(
322    get,
323    path = "/{course_id}/modules",
324    operation_id = "getCmsCourseModules",
325    tag = "cms_courses",
326    params(
327        ("course_id" = Uuid, Path, description = "Course id")
328    ),
329    responses(
330        (status = 200, description = "Course modules", body = Vec<CourseModule>)
331    )
332)]
333async fn get_course_modules(
334    course_id: web::Path<Uuid>,
335    user: AuthUser,
336    pool: web::Data<PgPool>,
337) -> ControllerResult<web::Json<Vec<CourseModule>>> {
338    let mut conn = pool.acquire().await?;
339    let course_modules = models::course_modules::get_by_course_id(&mut conn, *course_id).await?;
340    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Course(*course_id)).await?;
341    token.authorized_ok(web::Json(course_modules))
342}
343
344/**
345GET `/api/v0/cms/courses/:course_id/course-instances` - Returns all course instances for given course id.
346*/
347#[instrument(skip(pool))]
348#[utoipa::path(
349    get,
350    path = "/{course_id}/course-instances",
351    operation_id = "getCmsCourseInstances",
352    tag = "cms_courses",
353    params(
354        ("course_id" = Uuid, Path, description = "Course id")
355    ),
356    responses(
357        (status = 200, description = "Course instances", body = Vec<CourseInstance>)
358    )
359)]
360async fn get_course_instances(
361    course_id: web::Path<Uuid>,
362    user: AuthUser,
363    pool: web::Data<PgPool>,
364) -> ControllerResult<web::Json<Vec<CourseInstance>>> {
365    let mut conn = pool.acquire().await?;
366    let instances =
367        models::course_instances::get_course_instances_for_course(&mut conn, *course_id).await?;
368    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Course(*course_id)).await?;
369    token.authorized_ok(web::Json(instances))
370}
371
372/**
373 POST /api/v0/main-frontend/courses/:course_id/partners_block - Create or updates a partners block for a course
374*/
375#[instrument(skip(payload, pool))]
376#[utoipa::path(
377    post,
378    path = "/{course_id}/partners-block",
379    operation_id = "upsertCmsCoursePartnersBlock",
380    tag = "cms_courses",
381    params(
382        ("course_id" = Uuid, Path, description = "Course id")
383    ),
384    request_body = Option<serde_json::Value>,
385    responses(
386        (status = 200, description = "Partners block upserted")
387    )
388)]
389async fn post_partners_block(
390    path: web::Path<Uuid>,
391    payload: web::Json<Option<serde_json::Value>>,
392    pool: web::Data<PgPool>,
393    user: AuthUser,
394) -> ControllerResult<web::Json<()>> {
395    let course_id = path.into_inner();
396
397    let content = payload.into_inner();
398    let mut conn = pool.acquire().await?;
399    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?;
400
401    models::partner_block::upsert_partner_block(&mut conn, course_id, content).await?;
402
403    token.authorized_ok(web::Json(()))
404}
405
406/**
407GET /courses/:course_id/partners_blocks - Gets a partners block related to a course
408*/
409#[instrument(skip(pool))]
410#[utoipa::path(
411    get,
412    path = "/{course_id}/partners-block",
413    operation_id = "getCmsCoursePartnersBlock",
414    tag = "cms_courses",
415    params(
416        ("course_id" = Uuid, Path, description = "Course id")
417    ),
418    responses(
419        (status = 200, description = "Partners block", body = PartnersBlock)
420    )
421)]
422async fn get_partners_block(
423    path: web::Path<Uuid>,
424    user: AuthUser,
425    pool: web::Data<PgPool>,
426) -> ControllerResult<web::Json<PartnersBlock>> {
427    let course_id = path.into_inner();
428    let mut conn = pool.acquire().await?;
429    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?;
430
431    // Check if the course exists in the partners_blocks table
432    let course_exists = models::partner_block::check_if_course_exists(&mut conn, course_id).await?;
433
434    let partner_block = if course_exists {
435        // If the course exists, fetch the partner block
436        models::partner_block::get_partner_block(&mut conn, course_id).await?
437    } else {
438        // If the course does not exist, create a new partner block with an empty content array
439        let empty_content: Option<serde_json::Value> = Some(serde_json::Value::Array(vec![]));
440
441        // Upsert the partner block with the empty content
442        models::partner_block::upsert_partner_block(&mut conn, course_id, empty_content).await?
443    };
444
445    token.authorized_ok(web::Json(partner_block))
446}
447
448/**
449DELETE `/api/v0/main-frontend/courses/:course_id` - Delete a partners block in a course.
450*/
451#[instrument(skip(pool))]
452#[utoipa::path(
453    delete,
454    path = "/{course_id}/partners-block",
455    operation_id = "deleteCmsCoursePartnersBlock",
456    tag = "cms_courses",
457    params(
458        ("course_id" = Uuid, Path, description = "Course id")
459    ),
460    responses(
461        (status = 200, description = "Deleted partners block", body = PartnersBlock)
462    )
463)]
464async fn delete_partners_block(
465    path: web::Path<Uuid>,
466    pool: web::Data<PgPool>,
467    user: AuthUser,
468) -> ControllerResult<web::Json<PartnersBlock>> {
469    let course_id = path.into_inner();
470    let mut conn = pool.acquire().await?;
471    let token = authorize(
472        &mut conn,
473        Act::UsuallyUnacceptableDeletion,
474        Some(user.id),
475        Res::Course(course_id),
476    )
477    .await?;
478    let deleted_partners_block =
479        models::partner_block::delete_partner_block(&mut conn, course_id).await?;
480
481    token.authorized_ok(web::Json(deleted_partners_block))
482}
483
484/**
485GET /api/v0/cms/courses/:course_id/nondefault-chatbot-configurations - Get all nondefault, enabled-to-students chatbot configurations of this course.
486*/
487#[instrument(skip(pool))]
488#[utoipa::path(
489    get,
490    path = "/{course_id}/nondefault-chatbot-configurations",
491    operation_id = "getCmsCourseNondefaultChatbotConfigurations",
492    tag = "cms_courses",
493    params(
494        ("course_id" = Uuid, Path, description = "Course id")
495    ),
496    responses(
497        (status = 200, description = "Chatbot configurations", body = Vec<ChatbotConfiguration>)
498    )
499)]
500async fn get_course_nondefault_chatbot_configurations(
501    path: web::Path<Uuid>,
502    pool: web::Data<PgPool>,
503    user: AuthUser,
504) -> ControllerResult<web::Json<Vec<ChatbotConfiguration>>> {
505    let course_id = path.into_inner();
506    let mut conn = pool.acquire().await?;
507    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?;
508    let course_chatbot_configurations =
509        models::chatbot_configurations::get_enabled_nondefault_for_course(&mut conn, course_id)
510            .await?;
511    token.authorized_ok(web::Json(course_chatbot_configurations))
512}
513
514/**
515Add a route for each controller in this module.
516
517The name starts with an underline in order to appear before other functions in the module documentation.
518
519We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
520*/
521pub fn _add_routes(cfg: &mut ServiceConfig) {
522    cfg.route("/{course_id}", web::get().to(get_course_by_id))
523        .route("/{course_id}/upload", web::post().to(add_media))
524        .route(
525            "/{course_id}/default-peer-review",
526            web::get().to(get_course_default_peer_or_self_review_configuration),
527        )
528        .route(
529            "/{course_id}/default-peer-review",
530            web::put().to(put_course_default_peer_or_self_review_configuration),
531        )
532        .route("/{course_id}/pages", web::get().to(get_all_pages))
533        .route(
534            "/{course_id}/research-consent-form",
535            web::get().to(get_research_form_with_course_id),
536        )
537        .route(
538            "/{course_id}/research-consent-form",
539            web::put().to(upsert_course_research_form),
540        )
541        .route(
542            "/{course_id}/partners-block",
543            web::post().to(post_partners_block),
544        )
545        .route(
546            "/{course_id}/partners-block",
547            web::get().to(get_partners_block),
548        )
549        .route(
550            "/{course_id}/partners-block",
551            web::delete().to(delete_partners_block),
552        )
553        .route("/{course_id}/modules", web::get().to(get_course_modules))
554        .route(
555            "/{course_id}/course-instances",
556            web::get().to(get_course_instances),
557        )
558        .route(
559            "/{course_id}/nondefault-chatbot-configurations",
560            web::get().to(get_course_nondefault_chatbot_configurations),
561        );
562}