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