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