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