Skip to main content

headless_lms_server/controllers/course_material/
courses.rs

1//! Controllers for requests starting with `/api/v0/course-material/courses`.
2
3use std::{collections::HashMap, net::IpAddr, path::Path};
4
5use actix_http::header::{self, X_FORWARDED_FOR};
6use actix_web::web::Json;
7use chrono::Utc;
8use futures::{FutureExt, future::OptionFuture};
9use headless_lms_models::courses::{CourseLanguageVersionNavigationInfo, CourseMaterialCourse};
10use headless_lms_models::{
11    course_custom_privacy_policy_checkbox_texts::CourseCustomPrivacyPolicyCheckboxText,
12    marketing_consents::UserMarketingConsent,
13};
14use headless_lms_models::{partner_block::PartnersBlock, privacy_link::PrivacyLink};
15use headless_lms_utils::ip_to_country::IpToCountryMapper;
16use isbot::Bots;
17use models::{
18    chapters::ChapterWithStatus,
19    course_instances::CourseInstance,
20    course_modules::CourseModule,
21    courses::{self, get_nondeleted_course_id_by_slug},
22    feedback,
23    feedback::NewFeedback,
24    glossary::Term,
25    material_references::MaterialReference,
26    page_visit_datum::NewPageVisitDatum,
27    page_visit_datum_daily_visit_hashing_keys::{
28        GenerateAnonymousIdentifierInput, generate_anonymous_identifier,
29    },
30    pages::{CoursePageWithUserData, Page, PageSearchResult, PageVisibility, SearchRequest},
31    proposed_page_edits::{self, NewProposedPageEdits},
32    research_forms::{
33        NewResearchFormQuestionAnswer, ResearchForm, ResearchFormQuestion,
34        ResearchFormQuestionAnswer,
35    },
36    student_countries::StudentCountry,
37    user_course_settings::UserCourseSettings,
38};
39use utoipa::{OpenApi, ToSchema};
40
41use crate::{
42    domain::authorization::{
43        Action, Resource, authorize_access_to_course_material,
44        authorize_with_fetched_list_of_roles, can_user_view_chapter, skip_authorize,
45    },
46    prelude::*,
47};
48
49#[derive(OpenApi)]
50#[openapi(paths(
51    get_course,
52    get_course_page_by_path,
53    get_current_course_instance,
54    get_course_instances,
55    get_public_course_pages,
56    get_chapters,
57    get_user_course_settings,
58    search_pages_with_phrase,
59    search_pages_with_words,
60    feedback,
61    propose_edit,
62    glossary,
63    get_material_references_by_course_id,
64    get_public_top_level_pages,
65    get_all_course_language_versions_navigation_info_from_page,
66    get_page_by_course_id_and_language_group,
67    student_country,
68    get_student_countries,
69    get_student_country,
70    get_research_form_with_course_id,
71    get_research_form_questions_with_course_id,
72    upsert_course_research_form_answer,
73    get_research_form_answers_with_user_id,
74    update_marketing_consent,
75    fetch_user_marketing_consent,
76    get_partners_block,
77    get_privacy_link,
78    get_custom_privacy_policy_checkbox_texts,
79    get_user_chapter_locks
80))]
81pub(crate) struct CourseMaterialCoursesApiDoc;
82
83/**
84GET `/api/v0/course-material/courses/:course_id` - Get course.
85*/
86#[utoipa::path(
87    get,
88    path = "/{course_id}",
89    operation_id = "getCourseMaterialCourse",
90    tag = "course-material-courses",
91    params(
92        ("course_id" = Uuid, Path, description = "Course id")
93    ),
94    responses(
95        (status = 200, description = "Course", body = CourseMaterialCourse)
96    )
97)]
98#[instrument(skip(pool))]
99async fn get_course(
100    course_id: web::Path<Uuid>,
101    pool: web::Data<PgPool>,
102    auth: Option<AuthUser>,
103) -> ControllerResult<web::Json<CourseMaterialCourse>> {
104    let mut conn = pool.acquire().await?;
105    let token =
106        authorize_access_to_course_material(&mut conn, auth.map(|u| u.id), *course_id).await?;
107    let course = models::courses::get_course(&mut conn, *course_id).await?;
108    token.authorized_ok(web::Json(course.into()))
109}
110
111/**
112GET `/:course_slug/page-by-path/...` - Returns a course page by path
113
114If the page has moved and there's a redirection, this will still return the moved page but the field `was_redirected` will indicate that the redirection happened. The new path can be found in the page object. The frontend is supposed to update the url of the page to the new location without reloading the page.
115
116# Example
117GET /api/v0/course-material/courses/introduction-to-everything/page-by-path//part-2/hello-world
118*/
119
120#[utoipa::path(
121    get,
122    path = "/{course_slug}/page-by-path/{url_path}",
123    operation_id = "getCourseMaterialCoursePageByPath",
124    tag = "course-material-courses",
125    params(
126        ("course_slug" = String, Path, description = "Course slug"),
127        ("url_path" = String, Path, description = "Page path within the course")
128    ),
129    responses(
130        (status = 200, description = "Course page with user data", body = CoursePageWithUserData)
131    )
132)]
133#[instrument(skip(pool, ip_to_country_mapper, req, file_store, app_conf))]
134async fn get_course_page_by_path(
135    params: web::Path<(String, String)>,
136    pool: web::Data<PgPool>,
137    user: Option<AuthUser>,
138    ip_to_country_mapper: web::Data<IpToCountryMapper>,
139    req: HttpRequest,
140    file_store: web::Data<dyn FileStore>,
141    app_conf: web::Data<ApplicationConfiguration>,
142) -> ControllerResult<web::Json<CoursePageWithUserData>> {
143    let mut conn = pool.acquire().await?;
144
145    let (course_slug, raw_page_path) = params.into_inner();
146    let path = if raw_page_path.starts_with('/') {
147        raw_page_path
148    } else {
149        format!("/{}", raw_page_path)
150    };
151    let user_id = user.map(|u| u.id);
152    let course_data = get_nondeleted_course_id_by_slug(&mut conn, &course_slug).await?;
153    let page_with_user_data = models::pages::get_page_with_user_data_by_path(
154        &mut conn,
155        user_id,
156        &course_data,
157        &path,
158        file_store.as_ref(),
159        &app_conf,
160    )
161    .await?;
162
163    // Chapters may be closed
164    if !can_user_view_chapter(
165        &mut conn,
166        user_id,
167        page_with_user_data.page.course_id,
168        page_with_user_data.page.chapter_id,
169    )
170    .await?
171    {
172        return Err(ControllerError::new(
173            ControllerErrorType::UnauthorizedWithReason(
174                crate::domain::error::UnauthorizedReason::ChapterNotOpenYet,
175            ),
176            "Chapter is not open yet.".to_string(),
177            None,
178        ));
179    }
180
181    let token = authorize_access_to_course_material(
182        &mut conn,
183        user_id,
184        page_with_user_data.page.course_id.ok_or_else(|| {
185            ControllerError::new(
186                ControllerErrorType::NotFound,
187                "Course not found".to_string(),
188                None,
189            )
190        })?,
191    )
192    .await?;
193
194    let temp_request_information =
195        derive_information_from_requester(req, ip_to_country_mapper).await?;
196
197    let RequestInformation {
198        ip,
199        referrer,
200        utm_source,
201        utm_medium,
202        utm_campaign,
203        utm_term,
204        utm_content,
205        country,
206        user_agent,
207        has_bot_user_agent,
208        browser_admits_its_a_bot,
209        browser,
210        browser_version,
211        operating_system,
212        operating_system_version,
213        device_type,
214    } = temp_request_information.data;
215
216    let course_or_exam_id = page_with_user_data
217        .page
218        .course_id
219        .unwrap_or_else(|| page_with_user_data.page.exam_id.unwrap_or_else(Uuid::nil));
220    let anonymous_identifier = generate_anonymous_identifier(
221        &mut conn,
222        GenerateAnonymousIdentifierInput {
223            user_agent,
224            ip_address: ip.map(|ip| ip.to_string()).unwrap_or_default(),
225            course_id: course_or_exam_id,
226        },
227    )
228    .await?;
229
230    models::page_visit_datum::insert(
231        &mut conn,
232        NewPageVisitDatum {
233            course_id: page_with_user_data.page.course_id,
234            page_id: page_with_user_data.page.id,
235            country,
236            browser,
237            browser_version,
238            operating_system,
239            operating_system_version,
240            device_type,
241            referrer,
242            is_bot: has_bot_user_agent || browser_admits_its_a_bot,
243            utm_source,
244            utm_medium,
245            utm_campaign,
246            utm_term,
247            utm_content,
248            anonymous_identifier,
249            exam_id: page_with_user_data.page.exam_id,
250        },
251    )
252    .await?;
253
254    token.authorized_ok(web::Json(page_with_user_data))
255}
256
257struct RequestInformation {
258    ip: Option<IpAddr>,
259    user_agent: String,
260    referrer: Option<String>,
261    utm_source: Option<String>,
262    utm_medium: Option<String>,
263    utm_campaign: Option<String>,
264    utm_term: Option<String>,
265    utm_content: Option<String>,
266    country: Option<String>,
267    has_bot_user_agent: bool,
268    browser_admits_its_a_bot: bool,
269    browser: Option<String>,
270    browser_version: Option<String>,
271    operating_system: Option<String>,
272    operating_system_version: Option<String>,
273    device_type: Option<String>,
274}
275
276/// Used in get_course_page_by_path for path for anonymous visitor counts
277async fn derive_information_from_requester(
278    req: HttpRequest,
279    ip_to_country_mapper: web::Data<IpToCountryMapper>,
280) -> ControllerResult<RequestInformation> {
281    let mut headers = req.headers().clone();
282    let x_real_ip = headers.get("X-Real-IP");
283    let x_forwarded_for = headers.get(X_FORWARDED_FOR);
284    let connection_info = req.connection_info();
285    let peer_address = connection_info.peer_addr();
286    let headers_clone = headers.clone();
287    let user_agent = headers_clone.get(header::USER_AGENT);
288    let bots = Bots::default();
289    let has_bot_user_agent = user_agent
290        .and_then(|ua| ua.to_str().ok())
291        .map(|ua| bots.is_bot(ua))
292        .unwrap_or(true);
293    // If this header is not set, the requester is considered a bot
294    let header_totally_not_a_bot = headers.get("totally-not-a-bot");
295    let browser_admits_its_a_bot = header_totally_not_a_bot.is_none();
296    if has_bot_user_agent || browser_admits_its_a_bot {
297        warn!(
298            ?has_bot_user_agent,
299            ?browser_admits_its_a_bot,
300            ?user_agent,
301            ?header_totally_not_a_bot,
302            "The requester is a bot"
303        )
304    }
305
306    let user_agent_parser = woothee::parser::Parser::new();
307    let parsed_user_agent = user_agent
308        .and_then(|ua| ua.to_str().ok())
309        .and_then(|ua| user_agent_parser.parse(ua));
310
311    let ip: Option<IpAddr> = connection_info
312        .realip_remote_addr()
313        .and_then(|ip| ip.parse::<IpAddr>().ok());
314
315    info!(
316        "Ip {:?}, x_real_ip {:?}, x_forwarded_for {:?}, peer_address {:?}",
317        ip, x_real_ip, x_forwarded_for, peer_address
318    );
319
320    let country = ip
321        .and_then(|ip| ip_to_country_mapper.map_ip_to_country(&ip))
322        .map(|c| c.to_string());
323
324    let utm_tags = headers
325        .remove("utm-tags")
326        .next()
327        .and_then(|utms| String::from_utf8(utms.as_bytes().to_vec()).ok())
328        .and_then(|utms| serde_json::from_str::<serde_json::Value>(&utms).ok())
329        .and_then(|o| o.as_object().cloned());
330
331    let utm_source = utm_tags
332        .clone()
333        .and_then(|mut tags| tags.remove("utm_source"))
334        .and_then(|v| v.as_str().map(|s| s.to_string()));
335
336    let utm_medium = utm_tags
337        .clone()
338        .and_then(|mut tags| tags.remove("utm_medium"))
339        .and_then(|v| v.as_str().map(|s| s.to_string()));
340
341    let utm_campaign = utm_tags
342        .clone()
343        .and_then(|mut tags| tags.remove("utm_campaign"))
344        .and_then(|v| v.as_str().map(|s| s.to_string()));
345
346    let utm_term = utm_tags
347        .clone()
348        .and_then(|mut tags| tags.remove("utm_term"))
349        .and_then(|v| v.as_str().map(|s| s.to_string()));
350
351    let utm_content = utm_tags
352        .and_then(|mut tags| tags.remove("utm_content"))
353        .and_then(|v| v.as_str().map(|s| s.to_string()));
354
355    let referrer = headers
356        .get("Orignal-Referrer")
357        .and_then(|r| r.to_str().ok())
358        .map(|r| r.to_string());
359
360    let browser = parsed_user_agent.as_ref().map(|ua| ua.name.to_string());
361    let browser_version = parsed_user_agent.as_ref().map(|ua| ua.version.to_string());
362    let operating_system = parsed_user_agent.as_ref().map(|ua| ua.os.to_string());
363    let operating_system_version = parsed_user_agent
364        .as_ref()
365        .map(|ua| ua.os_version.to_string());
366    let device_type = parsed_user_agent.as_ref().map(|ua| ua.category.to_string());
367    let token = skip_authorize();
368    token.authorized_ok(RequestInformation {
369        ip,
370        user_agent: user_agent
371            .and_then(|ua| ua.to_str().ok())
372            .unwrap_or_default()
373            .to_string(),
374        referrer,
375        utm_source,
376        utm_medium,
377        utm_campaign,
378        utm_term,
379        utm_content,
380        country,
381        has_bot_user_agent,
382        browser_admits_its_a_bot,
383        browser,
384        browser_version,
385        operating_system,
386        operating_system_version,
387        device_type,
388    })
389}
390
391/**
392GET `/api/v0/course-material/courses/:course_id/current-instance` - Returns the instance of a course for the current user, if there is one.
393*/
394#[utoipa::path(
395    get,
396    path = "/{course_id}/current-instance",
397    operation_id = "getCurrentCourseMaterialCourseInstance",
398    tag = "course-material-courses",
399    params(
400        ("course_id" = Uuid, Path, description = "Course id")
401    ),
402    responses(
403        (status = 200, description = "Current course instance", body = Option<CourseInstance>)
404    )
405)]
406#[instrument(skip(pool))]
407async fn get_current_course_instance(
408    pool: web::Data<PgPool>,
409    course_id: web::Path<Uuid>,
410    user: Option<AuthUser>,
411) -> ControllerResult<web::Json<Option<CourseInstance>>> {
412    let mut conn = pool.acquire().await?;
413    if let Some(user) = user {
414        let instance = models::course_instances::current_course_instance_of_user(
415            &mut conn, user.id, *course_id,
416        )
417        .await?;
418        let token = skip_authorize();
419        token.authorized_ok(web::Json(instance))
420    } else {
421        Err(ControllerError::new(
422            ControllerErrorType::NotFound,
423            "User not found".to_string(),
424            None,
425        ))
426    }
427}
428
429/**
430GET `/api/v0/course-material/courses/:course_id/course-instances` - Returns all course instances for given course id.
431*/
432#[utoipa::path(
433    get,
434    path = "/{course_id}/course-instances",
435    operation_id = "getCourseMaterialCourseInstances",
436    tag = "course-material-courses",
437    params(
438        ("course_id" = Uuid, Path, description = "Course id")
439    ),
440    responses(
441        (status = 200, description = "Course instances", body = Vec<CourseInstance>)
442    )
443)]
444async fn get_course_instances(
445    pool: web::Data<PgPool>,
446    course_id: web::Path<Uuid>,
447) -> ControllerResult<web::Json<Vec<CourseInstance>>> {
448    let mut conn = pool.acquire().await?;
449    let instances =
450        models::course_instances::get_course_instances_for_course(&mut conn, *course_id).await?;
451    let token = skip_authorize();
452    token.authorized_ok(web::Json(instances))
453}
454
455/**
456GET `/api/v0/course-material/courses/:course_id/pages` - Returns a list of public pages on a course.
457
458Since anyone can access this endpoint, any unlisted pages are omited from these results.
459*/
460#[utoipa::path(
461    get,
462    path = "/{course_id}/pages",
463    operation_id = "getCourseMaterialCoursePages",
464    tag = "course-material-courses",
465    params(
466        ("course_id" = Uuid, Path, description = "Course id")
467    ),
468    responses(
469        (status = 200, description = "Public course pages", body = Vec<Page>)
470    )
471)]
472#[instrument(skip(pool))]
473async fn get_public_course_pages(
474    course_id: web::Path<Uuid>,
475    pool: web::Data<PgPool>,
476    auth: Option<AuthUser>,
477) -> ControllerResult<web::Json<Vec<Page>>> {
478    let mut conn = pool.acquire().await?;
479    let user_id = auth.map(|u| u.id);
480    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
481    let pages: Vec<Page> = models::pages::get_all_by_course_id_and_visibility(
482        &mut conn,
483        *course_id,
484        PageVisibility::Public,
485    )
486    .await?;
487    let pages = models::pages::filter_course_material_pages(&mut conn, user_id, pages).await?;
488    token.authorized_ok(web::Json(pages))
489}
490
491#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
492
493pub struct ChaptersWithStatus {
494    pub is_previewable: bool,
495    pub modules: Vec<CourseMaterialCourseModule>,
496}
497
498#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
499
500pub struct CourseMaterialCourseModule {
501    pub chapters: Vec<ChapterWithStatus>,
502    pub id: Uuid,
503    pub is_default: bool,
504    pub name: Option<String>,
505    pub order_number: i32,
506}
507
508/**
509GET `/api/v0/course-material/courses/:course_id/chapters` - Returns a list of chapters in a course.
510*/
511
512#[utoipa::path(
513    get,
514    path = "/{course_id}/chapters",
515    operation_id = "getCourseMaterialChapters",
516    tag = "course-material-courses",
517    params(
518        ("course_id" = Uuid, Path, description = "Course id")
519    ),
520    responses(
521        (status = 200, description = "Course chapters grouped by module", body = ChaptersWithStatus)
522    )
523)]
524#[instrument(skip(pool, file_store, app_conf))]
525async fn get_chapters(
526    course_id: web::Path<Uuid>,
527    user: Option<AuthUser>,
528    pool: web::Data<PgPool>,
529    file_store: web::Data<dyn FileStore>,
530    app_conf: web::Data<ApplicationConfiguration>,
531) -> ControllerResult<web::Json<ChaptersWithStatus>> {
532    let mut conn = pool.acquire().await?;
533    let user_id = user.as_ref().map(|u| u.id);
534    let is_previewable = OptionFuture::from(user.map(|u| {
535        authorize(&mut conn, Act::Teach, Some(u.id), Res::Course(*course_id)).map(|r| r.ok())
536    }))
537    .await
538    .is_some();
539    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
540    let course_modules = models::course_modules::get_by_course_id(&mut conn, *course_id).await?;
541    let exercise_deadline_overrides =
542        models::chapters::exercise_deadline_overrides_by_chapter_for_course(&mut conn, *course_id)
543            .await?;
544    let chapters = models::chapters::course_chapters(&mut conn, *course_id)
545        .await?
546        .into_iter()
547        .map(|chapter| {
548            let chapter_image_url = chapter
549                .chapter_image_path
550                .as_ref()
551                .map(|path| file_store.get_download_url(Path::new(&path), &app_conf));
552            let exercise_deadline_overrides = exercise_deadline_overrides.get(&chapter.id).copied();
553            ChapterWithStatus::from_database_chapter_timestamp_and_image_url(
554                chapter,
555                Utc::now(),
556                chapter_image_url,
557                exercise_deadline_overrides,
558            )
559        })
560        .collect();
561    let modules = collect_course_modules(course_modules, chapters)?.data;
562    token.authorized_ok(web::Json(ChaptersWithStatus {
563        is_previewable,
564        modules,
565    }))
566}
567
568/// Combines course modules and chapters, consuming them.
569fn collect_course_modules(
570    course_modules: Vec<CourseModule>,
571    chapters: Vec<ChapterWithStatus>,
572) -> ControllerResult<Vec<CourseMaterialCourseModule>> {
573    let mut course_modules: HashMap<Uuid, CourseMaterialCourseModule> = course_modules
574        .into_iter()
575        .map(|course_module| {
576            (
577                course_module.id,
578                CourseMaterialCourseModule {
579                    chapters: vec![],
580                    id: course_module.id,
581                    is_default: course_module.name.is_none(),
582                    name: course_module.name,
583                    order_number: course_module.order_number,
584                },
585            )
586        })
587        .collect();
588    for chapter in chapters {
589        course_modules
590            .get_mut(&chapter.course_module_id)
591            .ok_or_else(|| {
592                ControllerError::new(
593                    ControllerErrorType::InternalServerError,
594                    "Module data mismatch.".to_string(),
595                    None,
596                )
597            })?
598            .chapters
599            .push(chapter);
600    }
601    let token = skip_authorize();
602    token.authorized_ok(course_modules.into_values().collect())
603}
604
605/**
606GET `/api/v0/course-material/courses/:course_id/user-settings` - Returns user settings for the current course.
607*/
608#[utoipa::path(
609    get,
610    path = "/{course_id}/user-settings",
611    operation_id = "getCourseMaterialUserCourseSettings",
612    tag = "course-material-courses",
613    params(
614        ("course_id" = Uuid, Path, description = "Course id")
615    ),
616    responses(
617        (status = 200, description = "User course settings", body = Option<UserCourseSettings>)
618    )
619)]
620#[instrument(skip(pool))]
621async fn get_user_course_settings(
622    pool: web::Data<PgPool>,
623    course_id: web::Path<Uuid>,
624    user: Option<AuthUser>,
625) -> ControllerResult<web::Json<Option<UserCourseSettings>>> {
626    let mut conn = pool.acquire().await?;
627    if let Some(user) = user {
628        let settings = models::user_course_settings::get_user_course_settings_by_course_id(
629            &mut conn, user.id, *course_id,
630        )
631        .await?;
632        let token = skip_authorize();
633        token.authorized_ok(web::Json(settings))
634    } else {
635        Err(ControllerError::new(
636            ControllerErrorType::NotFound,
637            "User not found".to_string(),
638            None,
639        ))
640    }
641}
642
643/**
644POST `/api/v0/course-material/courses/:course_id/search-pages-with-phrase` - Returns a list of pages given a search query.
645
646Provided words are supposed to appear right after each other in the source document.
647
648# Example
649
650Request:
651
652```http
653POST /api/v0/course-material/courses/1a68e8b0-d151-4c0e-9307-bb154e9d2be1/search-pages-with-phrase HTTP/1.1
654Content-Type: application/json
655
656{
657  "query": "Everything"
658}
659```
660*/
661#[utoipa::path(
662    post,
663    path = "/{course_id}/search-pages-with-phrase",
664    operation_id = "searchPagesWithPhrase",
665    tag = "course-material-courses",
666    params(
667        ("course_id" = Uuid, Path, description = "Course id")
668    ),
669    request_body = SearchRequest,
670    responses(
671        (status = 200, description = "Matching pages", body = Vec<PageSearchResult>)
672    )
673)]
674#[instrument(skip(pool))]
675async fn search_pages_with_phrase(
676    course_id: web::Path<Uuid>,
677    payload: web::Json<SearchRequest>,
678    pool: web::Data<PgPool>,
679    auth: Option<AuthUser>,
680) -> ControllerResult<web::Json<Vec<PageSearchResult>>> {
681    let mut conn = pool.acquire().await?;
682    let token =
683        authorize_access_to_course_material(&mut conn, auth.map(|u| u.id), *course_id).await?;
684    let res =
685        models::pages::get_page_search_results_for_phrase(&mut conn, *course_id, &payload).await?;
686    token.authorized_ok(web::Json(res))
687}
688
689/**
690POST `/api/v0/course-material/courses/:course_id/search-pages-with-words` - Returns a list of pages given a search query.
691
692Provided words can appear in any order in the source document.
693
694# Example
695
696Request:
697
698```http
699POST /api/v0/course-material/courses/1a68e8b0-d151-4c0e-9307-bb154e9d2be1/search-pages-with-words HTTP/1.1
700Content-Type: application/json
701
702{
703  "query": "Everything"
704}
705```
706*/
707#[utoipa::path(
708    post,
709    path = "/{course_id}/search-pages-with-words",
710    operation_id = "searchPagesWithWords",
711    tag = "course-material-courses",
712    params(
713        ("course_id" = Uuid, Path, description = "Course id")
714    ),
715    request_body = SearchRequest,
716    responses(
717        (status = 200, description = "Matching pages", body = Vec<PageSearchResult>)
718    )
719)]
720#[instrument(skip(pool))]
721async fn search_pages_with_words(
722    course_id: web::Path<Uuid>,
723    payload: web::Json<SearchRequest>,
724    pool: web::Data<PgPool>,
725    auth: Option<AuthUser>,
726) -> ControllerResult<web::Json<Vec<PageSearchResult>>> {
727    let mut conn = pool.acquire().await?;
728    let token =
729        authorize_access_to_course_material(&mut conn, auth.map(|u| u.id), *course_id).await?;
730    let res =
731        models::pages::get_page_search_results_for_words(&mut conn, *course_id, &payload).await?;
732    token.authorized_ok(web::Json(res))
733}
734
735/**
736POST `/api/v0/course-material/courses/:course_id/feedback` - Creates new feedback.
737*/
738#[utoipa::path(
739    post,
740    path = "/{course_id}/feedback",
741    operation_id = "postFeedback",
742    tag = "course-material-courses",
743    params(
744        ("course_id" = Uuid, Path, description = "Course id")
745    ),
746    request_body = Vec<NewFeedback>,
747    responses(
748        (status = 200, description = "Created feedback ids", body = Vec<Uuid>)
749    )
750)]
751pub async fn feedback(
752    course_id: web::Path<Uuid>,
753    new_feedback: web::Json<Vec<NewFeedback>>,
754    pool: web::Data<PgPool>,
755    user: Option<AuthUser>,
756) -> ControllerResult<web::Json<Vec<Uuid>>> {
757    let mut conn = pool.acquire().await?;
758    let fs = new_feedback.into_inner();
759    let user_id = user.as_ref().map(|u| u.id);
760
761    // validate
762    for f in &fs {
763        if f.feedback_given.len() > 1000 {
764            return Err(ControllerError::new(
765                ControllerErrorType::BadRequest,
766                "Feedback given too long: max 1000".to_string(),
767                None,
768            ));
769        }
770        if f.related_blocks.len() > 100 {
771            return Err(ControllerError::new(
772                ControllerErrorType::BadRequest,
773                "Too many related blocks: max 100".to_string(),
774                None,
775            ));
776        }
777        for block in &f.related_blocks {
778            if block.text.as_ref().map(|t| t.len()).unwrap_or_default() > 10000 {
779                return Err(ControllerError::new(
780                    ControllerErrorType::BadRequest,
781                    "Block text too long: max 10000".to_string(),
782                    None,
783                ));
784            }
785        }
786    }
787
788    let mut tx = conn.begin().await?;
789    let mut ids = vec![];
790    for f in fs {
791        let id = feedback::insert(&mut tx, PKeyPolicy::Generate, user_id, *course_id, f).await?;
792        ids.push(id);
793    }
794    tx.commit().await?;
795    let token = skip_authorize();
796    token.authorized_ok(web::Json(ids))
797}
798
799/**
800POST `/api/v0/course-material/courses/:course_slug/edit` - Creates a new edit proposal.
801*/
802#[utoipa::path(
803    post,
804    path = "/{course_slug}/propose-edit",
805    operation_id = "postCourseMaterialCourseEditProposal",
806    tag = "course-material-courses",
807    params(
808        ("course_slug" = String, Path, description = "Course slug")
809    ),
810    request_body = NewProposedPageEdits,
811    responses(
812        (status = 200, description = "Created edit proposal id", body = Uuid)
813    )
814)]
815async fn propose_edit(
816    course_slug: web::Path<String>,
817    edits: web::Json<NewProposedPageEdits>,
818    pool: web::Data<PgPool>,
819    user: Option<AuthUser>,
820) -> ControllerResult<web::Json<Uuid>> {
821    let mut conn = pool.acquire().await?;
822    let course = courses::get_course_by_slug(&mut conn, course_slug.as_str()).await?;
823    let edits = edits.into_inner();
824    let token =
825        authorize_access_to_course_material(&mut conn, user.as_ref().map(|u| u.id), course.id)
826            .await?;
827    let (id, _) = proposed_page_edits::create_for_page_id_and_course_id(
828        &mut conn,
829        PKeyPolicy::Generate,
830        course.id,
831        user.map(|u| u.id),
832        &edits,
833    )
834    .await?;
835    token.authorized_ok(web::Json(id))
836}
837
838#[utoipa::path(
839    get,
840    path = "/{course_id}/glossary",
841    operation_id = "getCourseMaterialGlossary",
842    tag = "course-material-courses",
843    params(
844        ("course_id" = Uuid, Path, description = "Course id")
845    ),
846    responses(
847        (status = 200, description = "Course glossary", body = Vec<Term>)
848    )
849)]
850#[instrument(skip(pool))]
851async fn glossary(
852    pool: web::Data<PgPool>,
853    course_id: web::Path<Uuid>,
854    auth: Option<AuthUser>,
855) -> ControllerResult<web::Json<Vec<Term>>> {
856    let mut conn = pool.acquire().await?;
857    let token =
858        authorize_access_to_course_material(&mut conn, auth.map(|u| u.id), *course_id).await?;
859    let glossary = models::glossary::fetch_for_course(&mut conn, *course_id).await?;
860    token.authorized_ok(web::Json(glossary))
861}
862
863#[utoipa::path(
864    get,
865    path = "/{course_id}/references",
866    operation_id = "getCourseMaterialReferences",
867    tag = "course-material-courses",
868    params(
869        ("course_id" = Uuid, Path, description = "Course id")
870    ),
871    responses(
872        (status = 200, description = "Course references", body = Vec<MaterialReference>)
873    )
874)]
875#[instrument(skip(pool))]
876async fn get_material_references_by_course_id(
877    course_id: web::Path<Uuid>,
878    pool: web::Data<PgPool>,
879    user: Option<AuthUser>,
880) -> ControllerResult<web::Json<Vec<MaterialReference>>> {
881    let mut conn = pool.acquire().await?;
882    let token =
883        authorize_access_to_course_material(&mut conn, user.map(|u| u.id), *course_id).await?;
884    let res =
885        models::material_references::get_references_by_course_id(&mut conn, *course_id).await?;
886
887    token.authorized_ok(web::Json(res))
888}
889
890/**
891GET /api/v0/course-material/courses/:course_id/top-level-pages
892*/
893#[utoipa::path(
894    get,
895    path = "/{course_id}/top-level-pages",
896    operation_id = "getCourseMaterialTopLevelPages",
897    tag = "course-material-courses",
898    params(
899        ("course_id" = Uuid, Path, description = "Course id")
900    ),
901    responses(
902        (status = 200, description = "Top-level pages", body = Vec<Page>)
903    )
904)]
905#[instrument(skip(pool))]
906async fn get_public_top_level_pages(
907    course_id: web::Path<Uuid>,
908    pool: web::Data<PgPool>,
909    auth: Option<AuthUser>,
910) -> ControllerResult<web::Json<Vec<Page>>> {
911    let mut conn = pool.acquire().await?;
912    let user_id = auth.map(|u| u.id);
913    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
914    let page = models::pages::get_course_top_level_pages_by_course_id_and_visibility(
915        &mut conn,
916        *course_id,
917        PageVisibility::Public,
918    )
919    .await?;
920    let page = models::pages::filter_course_material_pages(&mut conn, user_id, page).await?;
921    token.authorized_ok(web::Json(page))
922}
923
924/**
925GET `/api/v0/course-material/courses/:id/language-versions-navigation-info/from-page/:page_id` - Returns all language versions of the same course. Since this is for course material, this does not include draft courses. To make developing new courses easier, we include all draft courses that the user has access to.
926*/
927#[utoipa::path(
928    get,
929    path = "/{course_id}/language-versions-navigation-info/from-page/{page_id}",
930    operation_id = "getCourseMaterialLanguageVersionNavigationInfos",
931    tag = "course-material-courses",
932    params(
933        ("course_id" = Uuid, Path, description = "Course id"),
934        ("page_id" = Uuid, Path, description = "Page id")
935    ),
936    responses(
937        (status = 200, description = "Language version navigation info", body = Vec<CourseLanguageVersionNavigationInfo>)
938    )
939)]
940#[instrument(skip(pool))]
941async fn get_all_course_language_versions_navigation_info_from_page(
942    pool: web::Data<PgPool>,
943    path: web::Path<(Uuid, Uuid)>,
944    user: Option<AuthUser>,
945) -> ControllerResult<web::Json<Vec<CourseLanguageVersionNavigationInfo>>> {
946    let mut conn = pool.acquire().await?;
947    let (course_id, page_id) = path.into_inner();
948    let token = skip_authorize();
949    let course = models::courses::get_course(&mut conn, course_id).await?;
950
951    let unfiltered_language_versions =
952        models::courses::get_all_language_versions_of_course(&mut conn, &course).await?;
953
954    let all_pages_in_same_page_language_group =
955        models::page_language_groups::get_all_pages_in_page_language_group_mapping(
956            &mut conn, page_id,
957        )
958        .await?;
959
960    let mut accessible_courses = unfiltered_language_versions
961        .clone()
962        .into_iter()
963        .filter(|c| !c.is_draft)
964        .collect::<Vec<_>>();
965
966    // If user is logged in, check access if we need to add draft courses
967    if let Some(user_id) = user.map(|u| u.id) {
968        let user_roles = models::roles::get_roles(&mut conn, user_id).await?;
969
970        for course_version in unfiltered_language_versions.iter().filter(|c| c.is_draft) {
971            if authorize_with_fetched_list_of_roles(
972                &mut conn,
973                Action::ViewMaterial,
974                Some(user_id),
975                Resource::Course(course_version.id),
976                &user_roles,
977            )
978            .await
979            .is_ok()
980            {
981                accessible_courses.push(course_version.clone());
982            }
983        }
984    }
985
986    token.authorized_ok(web::Json(
987        accessible_courses
988            .into_iter()
989            .map(|c| {
990                let page_language_group_navigation_info =
991                    all_pages_in_same_page_language_group.get(&CourseOrExamId::Course(c.id));
992                CourseLanguageVersionNavigationInfo::from_course_and_page_info(
993                    &c,
994                    page_language_group_navigation_info,
995                )
996            })
997            .collect(),
998    ))
999}
1000
1001/**
1002GET `/api/v0/{course_id}/pages/by-language-group-id/{page_language_group_id} - Returns a page with the given course id and language group id.
1003 */
1004#[utoipa::path(
1005    get,
1006    path = "/{course_id}/pages/by-language-group-id/{page_language_group_id}",
1007    operation_id = "getCourseMaterialPageByCourseIdAndLanguageGroupId",
1008    tag = "course-material-courses",
1009    params(
1010        ("course_id" = Uuid, Path, description = "Course id"),
1011        ("page_language_group_id" = Uuid, Path, description = "Page language group id")
1012    ),
1013    responses(
1014        (status = 200, description = "Page in requested language group", body = Page)
1015    )
1016)]
1017#[instrument(skip(pool))]
1018async fn get_page_by_course_id_and_language_group(
1019    info: web::Path<(Uuid, Uuid)>,
1020    pool: web::Data<PgPool>,
1021    auth: Option<AuthUser>,
1022) -> ControllerResult<web::Json<Page>> {
1023    let mut conn = pool.acquire().await?;
1024    let (course_id, page_language_group_id) = info.into_inner();
1025    let user_id = auth.map(|u| u.id);
1026    let token = authorize_access_to_course_material(&mut conn, user_id, course_id).await?;
1027
1028    let page: Page = models::pages::get_page_by_course_id_and_language_group(
1029        &mut conn,
1030        course_id,
1031        page_language_group_id,
1032    )
1033    .await?;
1034    let page = models::pages::filter_course_material_page(&mut conn, user_id, page).await?;
1035    token.authorized_ok(web::Json(page))
1036}
1037
1038/**
1039POST `/api/v0/{course_id}/course-instances/{course_instance_id}/student-countries/{country_code}` - Add a new student's country entry.
1040*/
1041#[utoipa::path(
1042    post,
1043    path = "/{course_id}/course-instances/{course_instance_id}/student-countries/{country_code}",
1044    operation_id = "postCourseMaterialStudentCountry",
1045    tag = "course-material-courses",
1046    params(
1047        ("course_id" = Uuid, Path, description = "Course id"),
1048        ("course_instance_id" = Uuid, Path, description = "Course instance id"),
1049        ("country_code" = String, Path, description = "Country code")
1050    ),
1051    responses(
1052        (status = 200, description = "Student country recorded", body = bool)
1053    )
1054)]
1055#[instrument(skip(pool))]
1056async fn student_country(
1057    query: web::Path<(Uuid, Uuid, String)>,
1058    pool: web::Data<PgPool>,
1059    user: AuthUser,
1060) -> ControllerResult<Json<bool>> {
1061    let mut conn = pool.acquire().await?;
1062    let (course_id, course_instance_id, country_code) = query.into_inner();
1063
1064    models::student_countries::insert(
1065        &mut conn,
1066        user.id,
1067        course_id,
1068        course_instance_id,
1069        &country_code,
1070    )
1071    .await?;
1072    let token = skip_authorize();
1073
1074    token.authorized_ok(Json(true))
1075}
1076
1077/**
1078GET `/api/v0/{course_id}/course-instances/{course_instance_id}/student-countries - Returns countries of student registered in a course.
1079 */
1080#[utoipa::path(
1081    get,
1082    path = "/{course_id}/course-instances/{course_instance_id}/student-countries",
1083    operation_id = "getCourseMaterialStudentCountries",
1084    tag = "course-material-courses",
1085    params(
1086        ("course_id" = Uuid, Path, description = "Course id"),
1087        ("course_instance_id" = Uuid, Path, description = "Course instance id")
1088    ),
1089    responses(
1090        (status = 200, description = "Student country counts", body = HashMap<String, u32>)
1091    )
1092)]
1093#[instrument(skip(pool))]
1094async fn get_student_countries(
1095    query: web::Path<(Uuid, Uuid)>,
1096    pool: web::Data<PgPool>,
1097    user: AuthUser,
1098) -> ControllerResult<web::Json<HashMap<String, u32>>> {
1099    let mut conn = pool.acquire().await?;
1100    let token = skip_authorize();
1101    let (course_id, course_instance_id) = query.into_inner();
1102
1103    let country_codes: Vec<String> =
1104        models::student_countries::get_countries(&mut conn, course_id, course_instance_id)
1105            .await?
1106            .into_iter()
1107            .map(|c| c.country_code)
1108            .collect();
1109
1110    let mut frequency: HashMap<String, u32> = HashMap::new();
1111    for code in country_codes {
1112        *frequency.entry(code).or_insert(0) += 1
1113    }
1114
1115    token.authorized_ok(web::Json(frequency))
1116}
1117
1118/**
1119GET `/api/v0/{course_id}/student-country - Returns country of a student registered in a course.
1120 */
1121#[utoipa::path(
1122    get,
1123    path = "/{course_instance_id}/student-country",
1124    operation_id = "getCourseMaterialStudentCountry",
1125    tag = "course-material-courses",
1126    params(
1127        ("course_instance_id" = Uuid, Path, description = "Course instance id")
1128    ),
1129    responses(
1130        (status = 200, description = "Selected student country", body = StudentCountry)
1131    )
1132)]
1133#[instrument(skip(pool))]
1134async fn get_student_country(
1135    course_instance_id: web::Path<Uuid>,
1136    pool: web::Data<PgPool>,
1137    user: AuthUser,
1138) -> ControllerResult<web::Json<StudentCountry>> {
1139    let mut conn = pool.acquire().await?;
1140    let token = skip_authorize();
1141    let res = models::student_countries::get_selected_country_by_user_id(
1142        &mut conn,
1143        user.id,
1144        *course_instance_id,
1145    )
1146    .await?;
1147
1148    token.authorized_ok(web::Json(res))
1149}
1150
1151/**
1152GET `/api/v0/course-material/courses/:course_id/research-consent-form` - Fetches courses research form with course id.
1153*/
1154#[utoipa::path(
1155    get,
1156    path = "/{course_id}/research-consent-form",
1157    operation_id = "getCourseMaterialResearchConsentForm",
1158    tag = "course-material-courses",
1159    params(
1160        ("course_id" = Uuid, Path, description = "Course id")
1161    ),
1162    responses(
1163        (status = 200, description = "Research consent form", body = Option<ResearchForm>)
1164    )
1165)]
1166#[instrument(skip(pool))]
1167async fn get_research_form_with_course_id(
1168    course_id: web::Path<Uuid>,
1169    user: AuthUser,
1170    pool: web::Data<PgPool>,
1171) -> ControllerResult<web::Json<Option<ResearchForm>>> {
1172    let mut conn = pool.acquire().await?;
1173    let user_id = Some(user.id);
1174
1175    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
1176
1177    let res = models::research_forms::get_research_form_with_course_id(&mut conn, *course_id)
1178        .await
1179        .optional()?;
1180
1181    token.authorized_ok(web::Json(res))
1182}
1183
1184/**
1185GET `/api/v0/course-material/courses/:course_id/research-consent-form-questions` - Fetches courses research form questions with course id.
1186*/
1187#[utoipa::path(
1188    get,
1189    path = "/{course_id}/research-consent-form-questions",
1190    operation_id = "getCourseMaterialResearchConsentFormQuestions",
1191    tag = "course-material-courses",
1192    params(
1193        ("course_id" = Uuid, Path, description = "Course id")
1194    ),
1195    responses(
1196        (status = 200, description = "Research consent form questions", body = Vec<ResearchFormQuestion>)
1197    )
1198)]
1199#[instrument(skip(pool))]
1200async fn get_research_form_questions_with_course_id(
1201    course_id: web::Path<Uuid>,
1202    user: AuthUser,
1203    pool: web::Data<PgPool>,
1204) -> ControllerResult<web::Json<Vec<ResearchFormQuestion>>> {
1205    let mut conn = pool.acquire().await?;
1206    let user_id = Some(user.id);
1207
1208    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
1209    let res =
1210        models::research_forms::get_research_form_questions_with_course_id(&mut conn, *course_id)
1211            .await?;
1212
1213    token.authorized_ok(web::Json(res))
1214}
1215
1216/**
1217POST `/api/v0/course-material/courses/:course_id/research-consent-form-questions-answer` - Upserts users consent for a courses research form question.
1218*/
1219
1220#[utoipa::path(
1221    post,
1222    path = "/{course_id}/research-consent-form-questions-answer",
1223    operation_id = "postCourseMaterialResearchConsentFormAnswer",
1224    tag = "course-material-courses",
1225    params(
1226        ("course_id" = Uuid, Path, description = "Course id")
1227    ),
1228    request_body = NewResearchFormQuestionAnswer,
1229    responses(
1230        (status = 200, description = "Research consent answer id", body = Uuid)
1231    )
1232)]
1233#[instrument(skip(pool, payload))]
1234async fn upsert_course_research_form_answer(
1235    payload: web::Json<NewResearchFormQuestionAnswer>,
1236    pool: web::Data<PgPool>,
1237    course_id: web::Path<Uuid>,
1238    user: AuthUser,
1239) -> ControllerResult<web::Json<Uuid>> {
1240    let mut conn = pool.acquire().await?;
1241    let user_id = Some(user.id);
1242
1243    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
1244    let answer = payload.into_inner();
1245    let res = models::research_forms::upsert_answer_for_user_id_and_question_id(
1246        &mut conn,
1247        user.id,
1248        *course_id,
1249        answer.research_form_question_id,
1250        answer.research_consent,
1251    )
1252    .await?;
1253
1254    token.authorized_ok(web::Json(res))
1255}
1256
1257/**
1258GET `/api/v0/course/courses/:course_id/research-consent-form-users-answers` - Fetches users answers for courses research form.
1259*/
1260#[utoipa::path(
1261    get,
1262    path = "/{course_id}/research-consent-form-user-answers",
1263    operation_id = "getCourseMaterialResearchConsentFormAnswers",
1264    tag = "course-material-courses",
1265    params(
1266        ("course_id" = Uuid, Path, description = "Course id")
1267    ),
1268    responses(
1269        (status = 200, description = "Research consent answers", body = Vec<ResearchFormQuestionAnswer>)
1270    )
1271)]
1272#[instrument(skip(pool))]
1273async fn get_research_form_answers_with_user_id(
1274    course_id: web::Path<Uuid>,
1275    user: AuthUser,
1276    pool: web::Data<PgPool>,
1277) -> ControllerResult<web::Json<Vec<ResearchFormQuestionAnswer>>> {
1278    let mut conn = pool.acquire().await?;
1279    let user_id = Some(user.id);
1280
1281    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
1282
1283    let res = models::research_forms::get_research_form_answers_with_user_id(
1284        &mut conn, *course_id, user.id,
1285    )
1286    .await?;
1287
1288    token.authorized_ok(web::Json(res))
1289}
1290
1291#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
1292
1293pub struct UserMarketingConsentPayload {
1294    pub course_language_groups_id: Uuid,
1295    pub email_subscription: bool,
1296    pub marketing_consent: bool,
1297}
1298
1299/**
1300POST `/api/v0/course-material/courses/:course_id/user-marketing-consent` - Adds or updates user's marketing consent for a specific course.
1301*/
1302#[utoipa::path(
1303    post,
1304    path = "/{course_id}/user-marketing-consent",
1305    operation_id = "updateMarketingConsent",
1306    tag = "course-material-courses",
1307    params(
1308        ("course_id" = Uuid, Path, description = "Course id")
1309    ),
1310    request_body = UserMarketingConsentPayload,
1311    responses(
1312        (status = 200, description = "Marketing consent id", body = Uuid)
1313    )
1314)]
1315#[instrument(skip(pool, payload))]
1316async fn update_marketing_consent(
1317    payload: web::Json<UserMarketingConsentPayload>,
1318    pool: web::Data<PgPool>,
1319    course_id: web::Path<Uuid>,
1320    user: AuthUser,
1321) -> ControllerResult<web::Json<Uuid>> {
1322    let mut conn = pool.acquire().await?;
1323    let user_id = Some(user.id);
1324
1325    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
1326
1327    let email_subscription = if payload.email_subscription {
1328        "subscribed"
1329    } else {
1330        "unsubscribed"
1331    };
1332
1333    let result = models::marketing_consents::upsert_marketing_consent(
1334        &mut conn,
1335        *course_id,
1336        payload.course_language_groups_id,
1337        &user.id,
1338        email_subscription,
1339        payload.marketing_consent,
1340    )
1341    .await?;
1342
1343    token.authorized_ok(web::Json(result))
1344}
1345
1346/**
1347GET `/api/v0/course-material/courses/:course_id/fetch-user-marketing-consent`
1348*/
1349#[utoipa::path(
1350    get,
1351    path = "/{course_id}/fetch-user-marketing-consent",
1352    operation_id = "getCourseMaterialUserMarketingConsent",
1353    tag = "course-material-courses",
1354    params(
1355        ("course_id" = Uuid, Path, description = "Course id")
1356    ),
1357    responses(
1358        (status = 200, description = "User marketing consent", body = Option<UserMarketingConsent>)
1359    )
1360)]
1361#[instrument(skip(pool))]
1362async fn fetch_user_marketing_consent(
1363    pool: web::Data<PgPool>,
1364    course_id: web::Path<Uuid>,
1365    user: AuthUser,
1366) -> ControllerResult<web::Json<Option<UserMarketingConsent>>> {
1367    let mut conn = pool.acquire().await?;
1368    let user_id = Some(user.id);
1369
1370    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
1371
1372    let result =
1373        models::marketing_consents::fetch_user_marketing_consent(&mut conn, *course_id, &user.id)
1374            .await
1375            .ok();
1376
1377    token.authorized_ok(web::Json(result))
1378}
1379
1380/**
1381GET /courses/:course_id/partners-block - Gets a partners block related to a course
1382*/
1383#[utoipa::path(
1384    get,
1385    path = "/{course_id}/partners-block",
1386    operation_id = "getCourseMaterialPartnersBlock",
1387    tag = "course-material-courses",
1388    params(
1389        ("course_id" = Uuid, Path, description = "Course id")
1390    ),
1391    responses(
1392        (status = 200, description = "Partners block", body = Option<PartnersBlock>)
1393    )
1394)]
1395#[instrument(skip(pool))]
1396async fn get_partners_block(
1397    path: web::Path<Uuid>,
1398    pool: web::Data<PgPool>,
1399) -> ControllerResult<web::Json<Option<PartnersBlock>>> {
1400    let course_id = path.into_inner();
1401    let mut conn = pool.acquire().await?;
1402    let partner_block = models::partner_block::get_partner_block(&mut conn, course_id)
1403        .await
1404        .optional()?;
1405    let token = skip_authorize();
1406    token.authorized_ok(web::Json(partner_block))
1407}
1408
1409/**
1410GET /courses/:course_id/privacy_link - Gets a privacy link related to a course
1411*/
1412#[utoipa::path(
1413    get,
1414    path = "/{course_id}/privacy-link",
1415    operation_id = "getCourseMaterialPrivacyLink",
1416    tag = "course-material-courses",
1417    params(
1418        ("course_id" = Uuid, Path, description = "Course id")
1419    ),
1420    responses(
1421        (status = 200, description = "Privacy links", body = Vec<PrivacyLink>)
1422    )
1423)]
1424#[instrument(skip(pool))]
1425async fn get_privacy_link(
1426    course_id: web::Path<Uuid>,
1427    pool: web::Data<PgPool>,
1428) -> ControllerResult<web::Json<Vec<PrivacyLink>>> {
1429    let mut conn = pool.acquire().await?;
1430    let privacy_link = models::privacy_link::get_privacy_link(&mut conn, *course_id).await?;
1431    let token = skip_authorize();
1432    token.authorized_ok(web::Json(privacy_link))
1433}
1434
1435/**
1436GET /courses/:course_id/custom-privacy-policy-checkbox-texts - Used to get customized checkbox texts for courses that use a different privacy policy than all our other courses (e.g. the Elements of AI course). These texts are shown in the course settings dialog.
1437*/
1438#[utoipa::path(
1439    get,
1440    path = "/{course_id}/custom-privacy-policy-checkbox-texts",
1441    operation_id = "getCourseMaterialCustomPrivacyPolicyCheckboxTexts",
1442    tag = "course-material-courses",
1443    params(
1444        ("course_id" = Uuid, Path, description = "Course id")
1445    ),
1446    responses(
1447        (status = 200, description = "Custom privacy policy checkbox texts", body = Vec<CourseCustomPrivacyPolicyCheckboxText>)
1448    )
1449)]
1450#[instrument(skip(pool))]
1451async fn get_custom_privacy_policy_checkbox_texts(
1452    course_id: web::Path<Uuid>,
1453    pool: web::Data<PgPool>,
1454    user: AuthUser, // Ensure the user is authenticated
1455) -> ControllerResult<web::Json<Vec<CourseCustomPrivacyPolicyCheckboxText>>> {
1456    let mut conn = pool.acquire().await?;
1457
1458    let token = authorize_access_to_course_material(&mut conn, Some(user.id), *course_id).await?;
1459
1460    let texts = models::course_custom_privacy_policy_checkbox_texts::get_all_by_course_id(
1461        &mut conn, *course_id,
1462    )
1463    .await?;
1464
1465    token.authorized_ok(web::Json(texts))
1466}
1467
1468/**
1469GET `/api/v0/course-material/courses/:course_id/user-chapter-locks` - Get user's chapter locking statuses for course
1470
1471Returns all chapters that the authenticated user has unlocked or completed for the specified course.
1472**/
1473#[utoipa::path(
1474    get,
1475    path = "/{course_id}/user-chapter-locks",
1476    operation_id = "getCourseMaterialUserChapterLocks",
1477    tag = "course-material-courses",
1478    params(
1479        ("course_id" = Uuid, Path, description = "Course id")
1480    ),
1481    responses(
1482        (status = 200, description = "User chapter locking statuses", body = Vec<models::user_chapter_locking_statuses::UserChapterLockingStatus>)
1483    )
1484)]
1485#[instrument(skip(pool))]
1486async fn get_user_chapter_locks(
1487    course_id: web::Path<Uuid>,
1488    pool: web::Data<PgPool>,
1489    user: AuthUser,
1490) -> ControllerResult<web::Json<Vec<models::user_chapter_locking_statuses::UserChapterLockingStatus>>>
1491{
1492    use models::user_chapter_locking_statuses;
1493    let mut conn = pool.acquire().await?;
1494    let token = authorize_access_to_course_material(&mut conn, Some(user.id), *course_id).await?;
1495
1496    let statuses =
1497        user_chapter_locking_statuses::get_or_init_all_for_course(&mut conn, user.id, *course_id)
1498            .await?;
1499
1500    token.authorized_ok(web::Json(statuses))
1501}
1502
1503/**
1504Add a route for each controller in this module.
1505
1506The name starts with an underline in order to appear before other functions in the module documentation.
1507
1508We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
1509*/
1510pub fn _add_routes(cfg: &mut ServiceConfig) {
1511    cfg.route("/{course_id}", web::get().to(get_course))
1512        .route("/{course_id}/chapters", web::get().to(get_chapters))
1513        .route(
1514            "/{course_id}/course-instances",
1515            web::get().to(get_course_instances),
1516        )
1517        .route(
1518            "/{course_id}/current-instance",
1519            web::get().to(get_current_course_instance),
1520        )
1521        .route("/{course_id}/feedback", web::post().to(feedback))
1522        .route(
1523            "/{course_id}/page-by-path/{url_path:.*}",
1524            web::get().to(get_course_page_by_path),
1525        )
1526        .route(
1527            "/{course_id}/search-pages-with-phrase",
1528            web::post().to(search_pages_with_phrase),
1529        )
1530        .route(
1531            "/{course_id}/language-versions-navigation-info/from-page/{page_id}",
1532            web::get().to(get_all_course_language_versions_navigation_info_from_page),
1533        )
1534        .route(
1535            "/{course_id}/search-pages-with-words",
1536            web::post().to(search_pages_with_words),
1537        )
1538        .route(
1539            "/{course_id}/user-settings",
1540            web::get().to(get_user_course_settings),
1541        )
1542        .route(
1543            "/{course_id}/top-level-pages",
1544            web::get().to(get_public_top_level_pages),
1545        )
1546        .route("/{course_id}/propose-edit", web::post().to(propose_edit))
1547        .route("/{course_id}/glossary", web::get().to(glossary))
1548        .route(
1549            "/{course_id}/references",
1550            web::get().to(get_material_references_by_course_id),
1551        )
1552        .route(
1553            "/{course_id}/pages/by-language-group-id/{page_language_group_id}",
1554            web::get().to(get_page_by_course_id_and_language_group),
1555        )
1556        .route("/{course_id}/pages", web::get().to(get_public_course_pages))
1557        .route(
1558            "/{course_id}/course-instances/{course_instance_id}/student-countries/{country_code}",
1559            web::post().to(student_country),
1560        )
1561        .route(
1562            "/{course_instance_id}/student-country",
1563            web::get().to(get_student_country),
1564        )
1565        .route(
1566            "/{course_id}/course-instances/{course_instance_id}/student-countries",
1567            web::get().to(get_student_countries),
1568        )
1569        .route(
1570            "/{course_id}/research-consent-form-questions-answer",
1571            web::post().to(upsert_course_research_form_answer),
1572        )
1573        .route(
1574            "/{courseId}/research-consent-form-user-answers",
1575            web::get().to(get_research_form_answers_with_user_id),
1576        )
1577        .route(
1578            "/{course_id}/research-consent-form",
1579            web::get().to(get_research_form_with_course_id),
1580        )
1581        .route(
1582            "/{course_id}/partners-block",
1583            web::get().to(get_partners_block),
1584        )
1585        .route("/{course_id}/privacy-link", web::get().to(get_privacy_link))
1586        .route(
1587            "/{course_id}/research-consent-form-questions",
1588            web::get().to(get_research_form_questions_with_course_id),
1589        )
1590        .route(
1591            "/{course_id}/user-marketing-consent",
1592            web::post().to(update_marketing_consent),
1593        )
1594        .route(
1595            "/{course_id}/fetch-user-marketing-consent",
1596            web::get().to(fetch_user_marketing_consent),
1597        )
1598        .route(
1599            "/{course_id}/custom-privacy-policy-checkbox-texts",
1600            web::get().to(get_custom_privacy_policy_checkbox_texts),
1601        )
1602        .route(
1603            "/{course_id}/user-chapter-locks",
1604            web::get().to(get_user_chapter_locks),
1605        );
1606}