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