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, can_user_view_chapter, is_permitted,
45        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 is_permitted(
985                &mut conn,
986                Action::ViewMaterial,
987                Resource::Course(course_version.id),
988                &user_roles,
989            )
990            .await
991            .unwrap_or(false)
992            {
993                accessible_courses.push(course_version.clone());
994            }
995        }
996    }
997
998    token.authorized_ok(web::Json(
999        accessible_courses
1000            .into_iter()
1001            .map(|c| {
1002                let page_language_group_navigation_info =
1003                    all_pages_in_same_page_language_group.get(&CourseOrExamId::Course(c.id));
1004                CourseLanguageVersionNavigationInfo::from_course_and_page_info(
1005                    &c,
1006                    page_language_group_navigation_info,
1007                )
1008            })
1009            .collect(),
1010    ))
1011}
1012
1013/**
1014GET `/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.
1015 */
1016#[utoipa::path(
1017    get,
1018    path = "/{course_id}/pages/by-language-group-id/{page_language_group_id}",
1019    operation_id = "getCourseMaterialPageByCourseIdAndLanguageGroupId",
1020    tag = "course-material-courses",
1021    params(
1022        ("course_id" = Uuid, Path, description = "Course id"),
1023        ("page_language_group_id" = Uuid, Path, description = "Page language group id")
1024    ),
1025    responses(
1026        (status = 200, description = "Page in requested language group", body = Page)
1027    )
1028)]
1029#[instrument(skip(pool))]
1030async fn get_page_by_course_id_and_language_group(
1031    info: web::Path<(Uuid, Uuid)>,
1032    pool: web::Data<PgPool>,
1033    auth: Option<AuthUser>,
1034) -> ControllerResult<web::Json<Page>> {
1035    let mut conn = pool.acquire().await?;
1036    let (course_id, page_language_group_id) = info.into_inner();
1037    let user_id = auth.map(|u| u.id);
1038    let token = authorize_access_to_course_material(&mut conn, user_id, course_id).await?;
1039
1040    let page: Page = models::pages::get_page_by_course_id_and_language_group(
1041        &mut conn,
1042        course_id,
1043        page_language_group_id,
1044    )
1045    .await?;
1046    let page = models::pages::filter_course_material_page(&mut conn, user_id, page).await?;
1047    token.authorized_ok(web::Json(page))
1048}
1049
1050/**
1051POST `/api/v0/{course_id}/course-instances/{course_instance_id}/student-countries/{country_code}` - Add a new student's country entry.
1052*/
1053#[utoipa::path(
1054    post,
1055    path = "/{course_id}/course-instances/{course_instance_id}/student-countries/{country_code}",
1056    operation_id = "postCourseMaterialStudentCountry",
1057    tag = "course-material-courses",
1058    params(
1059        ("course_id" = Uuid, Path, description = "Course id"),
1060        ("course_instance_id" = Uuid, Path, description = "Course instance id"),
1061        ("country_code" = String, Path, description = "Country code")
1062    ),
1063    responses(
1064        (status = 200, description = "Student country recorded", body = bool)
1065    )
1066)]
1067#[instrument(skip(pool))]
1068async fn student_country(
1069    query: web::Path<(Uuid, Uuid, String)>,
1070    pool: web::Data<PgPool>,
1071    user: AuthUser,
1072) -> ControllerResult<Json<bool>> {
1073    let mut conn = pool.acquire().await?;
1074    let (course_id, course_instance_id, country_code) = query.into_inner();
1075
1076    models::student_countries::insert(
1077        &mut conn,
1078        user.id,
1079        course_id,
1080        course_instance_id,
1081        &country_code,
1082    )
1083    .await?;
1084    let token = skip_authorize();
1085
1086    token.authorized_ok(Json(true))
1087}
1088
1089/**
1090GET `/api/v0/{course_id}/course-instances/{course_instance_id}/student-countries - Returns countries of student registered in a course.
1091 */
1092#[utoipa::path(
1093    get,
1094    path = "/{course_id}/course-instances/{course_instance_id}/student-countries",
1095    operation_id = "getCourseMaterialStudentCountries",
1096    tag = "course-material-courses",
1097    params(
1098        ("course_id" = Uuid, Path, description = "Course id"),
1099        ("course_instance_id" = Uuid, Path, description = "Course instance id")
1100    ),
1101    responses(
1102        (status = 200, description = "Student country counts", body = HashMap<String, u32>)
1103    )
1104)]
1105#[instrument(skip(pool))]
1106async fn get_student_countries(
1107    query: web::Path<(Uuid, Uuid)>,
1108    pool: web::Data<PgPool>,
1109    user: AuthUser,
1110) -> ControllerResult<web::Json<HashMap<String, u32>>> {
1111    let mut conn = pool.acquire().await?;
1112    let token = skip_authorize();
1113    let (course_id, course_instance_id) = query.into_inner();
1114
1115    let country_codes: Vec<String> =
1116        models::student_countries::get_countries(&mut conn, course_id, course_instance_id)
1117            .await?
1118            .into_iter()
1119            .map(|c| c.country_code)
1120            .collect();
1121
1122    let mut frequency: HashMap<String, u32> = HashMap::new();
1123    for code in country_codes {
1124        *frequency.entry(code).or_insert(0) += 1
1125    }
1126
1127    token.authorized_ok(web::Json(frequency))
1128}
1129
1130/**
1131GET `/api/v0/{course_id}/student-country - Returns country of a student registered in a course.
1132 */
1133#[utoipa::path(
1134    get,
1135    path = "/{course_instance_id}/student-country",
1136    operation_id = "getCourseMaterialStudentCountry",
1137    tag = "course-material-courses",
1138    params(
1139        ("course_instance_id" = Uuid, Path, description = "Course instance id")
1140    ),
1141    responses(
1142        (status = 200, description = "Selected student country", body = StudentCountry)
1143    )
1144)]
1145#[instrument(skip(pool))]
1146async fn get_student_country(
1147    course_instance_id: web::Path<Uuid>,
1148    pool: web::Data<PgPool>,
1149    user: AuthUser,
1150) -> ControllerResult<web::Json<StudentCountry>> {
1151    let mut conn = pool.acquire().await?;
1152    let token = skip_authorize();
1153    let res = models::student_countries::get_selected_country_by_user_id(
1154        &mut conn,
1155        user.id,
1156        *course_instance_id,
1157    )
1158    .await?;
1159
1160    token.authorized_ok(web::Json(res))
1161}
1162
1163/**
1164GET `/api/v0/course-material/courses/:course_id/research-consent-form` - Fetches courses research form with course id.
1165*/
1166#[utoipa::path(
1167    get,
1168    path = "/{course_id}/research-consent-form",
1169    operation_id = "getCourseMaterialResearchConsentForm",
1170    tag = "course-material-courses",
1171    params(
1172        ("course_id" = Uuid, Path, description = "Course id")
1173    ),
1174    responses(
1175        (status = 200, description = "Research consent form", body = Option<ResearchForm>)
1176    )
1177)]
1178#[instrument(skip(pool))]
1179async fn get_research_form_with_course_id(
1180    course_id: web::Path<Uuid>,
1181    user: AuthUser,
1182    pool: web::Data<PgPool>,
1183) -> ControllerResult<web::Json<Option<ResearchForm>>> {
1184    let mut conn = pool.acquire().await?;
1185    let user_id = Some(user.id);
1186
1187    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
1188
1189    let res = models::research_forms::get_research_form_with_course_id(&mut conn, *course_id)
1190        .await
1191        .optional()?;
1192
1193    token.authorized_ok(web::Json(res))
1194}
1195
1196/**
1197GET `/api/v0/course-material/courses/:course_id/research-consent-form-questions` - Fetches courses research form questions with course id.
1198*/
1199#[utoipa::path(
1200    get,
1201    path = "/{course_id}/research-consent-form-questions",
1202    operation_id = "getCourseMaterialResearchConsentFormQuestions",
1203    tag = "course-material-courses",
1204    params(
1205        ("course_id" = Uuid, Path, description = "Course id")
1206    ),
1207    responses(
1208        (status = 200, description = "Research consent form questions", body = Vec<ResearchFormQuestion>)
1209    )
1210)]
1211#[instrument(skip(pool))]
1212async fn get_research_form_questions_with_course_id(
1213    course_id: web::Path<Uuid>,
1214    user: AuthUser,
1215    pool: web::Data<PgPool>,
1216) -> ControllerResult<web::Json<Vec<ResearchFormQuestion>>> {
1217    let mut conn = pool.acquire().await?;
1218    let user_id = Some(user.id);
1219
1220    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
1221    let res =
1222        models::research_forms::get_research_form_questions_with_course_id(&mut conn, *course_id)
1223            .await?;
1224
1225    token.authorized_ok(web::Json(res))
1226}
1227
1228/**
1229POST `/api/v0/course-material/courses/:course_id/research-consent-form-questions-answer` - Upserts users consent for a courses research form question.
1230*/
1231
1232#[utoipa::path(
1233    post,
1234    path = "/{course_id}/research-consent-form-questions-answer",
1235    operation_id = "postCourseMaterialResearchConsentFormAnswer",
1236    tag = "course-material-courses",
1237    params(
1238        ("course_id" = Uuid, Path, description = "Course id")
1239    ),
1240    request_body = NewResearchFormQuestionAnswer,
1241    responses(
1242        (status = 200, description = "Research consent answer id", body = Uuid)
1243    )
1244)]
1245#[instrument(skip(pool, payload))]
1246async fn upsert_course_research_form_answer(
1247    payload: web::Json<NewResearchFormQuestionAnswer>,
1248    pool: web::Data<PgPool>,
1249    course_id: web::Path<Uuid>,
1250    user: AuthUser,
1251) -> ControllerResult<web::Json<Uuid>> {
1252    let mut conn = pool.acquire().await?;
1253    let user_id = Some(user.id);
1254
1255    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
1256    let answer = payload.into_inner();
1257    let res = models::research_forms::upsert_answer_for_user_id_and_question_id(
1258        &mut conn,
1259        user.id,
1260        *course_id,
1261        answer.research_form_question_id,
1262        answer.research_consent,
1263    )
1264    .await?;
1265
1266    token.authorized_ok(web::Json(res))
1267}
1268
1269/**
1270GET `/api/v0/course/courses/:course_id/research-consent-form-users-answers` - Fetches users answers for courses research form.
1271*/
1272#[utoipa::path(
1273    get,
1274    path = "/{course_id}/research-consent-form-user-answers",
1275    operation_id = "getCourseMaterialResearchConsentFormAnswers",
1276    tag = "course-material-courses",
1277    params(
1278        ("course_id" = Uuid, Path, description = "Course id")
1279    ),
1280    responses(
1281        (status = 200, description = "Research consent answers", body = Vec<ResearchFormQuestionAnswer>)
1282    )
1283)]
1284#[instrument(skip(pool))]
1285async fn get_research_form_answers_with_user_id(
1286    course_id: web::Path<Uuid>,
1287    user: AuthUser,
1288    pool: web::Data<PgPool>,
1289) -> ControllerResult<web::Json<Vec<ResearchFormQuestionAnswer>>> {
1290    let mut conn = pool.acquire().await?;
1291    let user_id = Some(user.id);
1292
1293    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
1294
1295    let res = models::research_forms::get_research_form_answers_with_user_id(
1296        &mut conn, *course_id, user.id,
1297    )
1298    .await?;
1299
1300    token.authorized_ok(web::Json(res))
1301}
1302
1303#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
1304
1305pub struct UserMarketingConsentPayload {
1306    pub course_language_groups_id: Uuid,
1307    pub email_subscription: bool,
1308    pub marketing_consent: bool,
1309}
1310
1311/**
1312POST `/api/v0/course-material/courses/:course_id/user-marketing-consent` - Adds or updates user's marketing consent for a specific course.
1313*/
1314#[utoipa::path(
1315    post,
1316    path = "/{course_id}/user-marketing-consent",
1317    operation_id = "updateMarketingConsent",
1318    tag = "course-material-courses",
1319    params(
1320        ("course_id" = Uuid, Path, description = "Course id")
1321    ),
1322    request_body = UserMarketingConsentPayload,
1323    responses(
1324        (status = 200, description = "Marketing consent id", body = Uuid)
1325    )
1326)]
1327#[instrument(skip(pool, payload))]
1328async fn update_marketing_consent(
1329    payload: web::Json<UserMarketingConsentPayload>,
1330    pool: web::Data<PgPool>,
1331    course_id: web::Path<Uuid>,
1332    user: AuthUser,
1333) -> ControllerResult<web::Json<Uuid>> {
1334    let mut conn = pool.acquire().await?;
1335    let user_id = Some(user.id);
1336
1337    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
1338
1339    let email_subscription = if payload.email_subscription {
1340        "subscribed"
1341    } else {
1342        "unsubscribed"
1343    };
1344
1345    let result = models::marketing_consents::upsert_marketing_consent(
1346        &mut conn,
1347        *course_id,
1348        payload.course_language_groups_id,
1349        &user.id,
1350        email_subscription,
1351        payload.marketing_consent,
1352    )
1353    .await?;
1354
1355    token.authorized_ok(web::Json(result))
1356}
1357
1358/**
1359GET `/api/v0/course-material/courses/:course_id/ai-usage-notice-acknowledgement` - Whether the
1360current user has acknowledged the AI-usage / academic-integrity notice for this course.
1361*/
1362#[utoipa::path(
1363    get,
1364    path = "/{course_id}/ai-usage-notice-acknowledgement",
1365    operation_id = "getAiUsageNoticeAcknowledgement",
1366    tag = "course-material-courses",
1367    params(
1368        ("course_id" = Uuid, Path, description = "Course id")
1369    ),
1370    responses(
1371        (status = 200, description = "Whether the user has acknowledged the notice", body = bool)
1372    )
1373)]
1374#[instrument(skip(pool))]
1375async fn get_ai_usage_notice_acknowledgement(
1376    pool: web::Data<PgPool>,
1377    course_id: web::Path<Uuid>,
1378    user: Option<AuthUser>,
1379) -> ControllerResult<web::Json<bool>> {
1380    let mut conn = pool.acquire().await?;
1381    let token = skip_authorize();
1382    let acknowledged = match user {
1383        Some(user) => {
1384            models::user_ai_usage_notice_acknowledgements::has_acknowledged(
1385                &mut conn, user.id, *course_id,
1386            )
1387            .await?
1388        }
1389        None => false,
1390    };
1391    token.authorized_ok(web::Json(acknowledged))
1392}
1393
1394/**
1395POST `/api/v0/course-material/courses/:course_id/ai-usage-notice-acknowledgement` - Records that
1396the current user has acknowledged the AI-usage / academic-integrity notice for this course.
1397*/
1398#[utoipa::path(
1399    post,
1400    path = "/{course_id}/ai-usage-notice-acknowledgement",
1401    operation_id = "acknowledgeAiUsageNotice",
1402    tag = "course-material-courses",
1403    params(
1404        ("course_id" = Uuid, Path, description = "Course id")
1405    ),
1406    responses(
1407        (status = 200, description = "Acknowledgement recorded", body = bool)
1408    )
1409)]
1410#[instrument(skip(pool))]
1411async fn acknowledge_ai_usage_notice(
1412    pool: web::Data<PgPool>,
1413    course_id: web::Path<Uuid>,
1414    user: AuthUser,
1415) -> ControllerResult<web::Json<bool>> {
1416    let mut conn = pool.acquire().await?;
1417    let token = authorize_access_to_course_material(&mut conn, Some(user.id), *course_id).await?;
1418    models::user_ai_usage_notice_acknowledgements::acknowledge(&mut conn, user.id, *course_id)
1419        .await?;
1420    token.authorized_ok(web::Json(true))
1421}
1422
1423/**
1424GET `/api/v0/course-material/courses/:course_id/fetch-user-marketing-consent`
1425*/
1426#[utoipa::path(
1427    get,
1428    path = "/{course_id}/fetch-user-marketing-consent",
1429    operation_id = "getCourseMaterialUserMarketingConsent",
1430    tag = "course-material-courses",
1431    params(
1432        ("course_id" = Uuid, Path, description = "Course id")
1433    ),
1434    responses(
1435        (status = 200, description = "User marketing consent", body = Option<UserMarketingConsent>)
1436    )
1437)]
1438#[instrument(skip(pool))]
1439async fn fetch_user_marketing_consent(
1440    pool: web::Data<PgPool>,
1441    course_id: web::Path<Uuid>,
1442    user: AuthUser,
1443) -> ControllerResult<web::Json<Option<UserMarketingConsent>>> {
1444    let mut conn = pool.acquire().await?;
1445    let user_id = Some(user.id);
1446
1447    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
1448
1449    let result =
1450        models::marketing_consents::fetch_user_marketing_consent(&mut conn, *course_id, &user.id)
1451            .await
1452            .ok();
1453
1454    token.authorized_ok(web::Json(result))
1455}
1456
1457/**
1458GET /courses/:course_id/partners-block - Gets a partners block related to a course
1459*/
1460#[utoipa::path(
1461    get,
1462    path = "/{course_id}/partners-block",
1463    operation_id = "getCourseMaterialPartnersBlock",
1464    tag = "course-material-courses",
1465    params(
1466        ("course_id" = Uuid, Path, description = "Course id")
1467    ),
1468    responses(
1469        (status = 200, description = "Partners block", body = Option<PartnersBlock>)
1470    )
1471)]
1472#[instrument(skip(pool))]
1473async fn get_partners_block(
1474    path: web::Path<Uuid>,
1475    pool: web::Data<PgPool>,
1476) -> ControllerResult<web::Json<Option<PartnersBlock>>> {
1477    let course_id = path.into_inner();
1478    let mut conn = pool.acquire().await?;
1479    let partner_block = models::partner_block::get_partner_block(&mut conn, course_id)
1480        .await
1481        .optional()?;
1482    let token = skip_authorize();
1483    token.authorized_ok(web::Json(partner_block))
1484}
1485
1486/**
1487GET /courses/:course_id/privacy_link - Gets a privacy link related to a course
1488*/
1489#[utoipa::path(
1490    get,
1491    path = "/{course_id}/privacy-link",
1492    operation_id = "getCourseMaterialPrivacyLink",
1493    tag = "course-material-courses",
1494    params(
1495        ("course_id" = Uuid, Path, description = "Course id")
1496    ),
1497    responses(
1498        (status = 200, description = "Privacy links", body = Vec<PrivacyLink>)
1499    )
1500)]
1501#[instrument(skip(pool))]
1502async fn get_privacy_link(
1503    course_id: web::Path<Uuid>,
1504    pool: web::Data<PgPool>,
1505) -> ControllerResult<web::Json<Vec<PrivacyLink>>> {
1506    let mut conn = pool.acquire().await?;
1507    let privacy_link = models::privacy_link::get_privacy_link(&mut conn, *course_id).await?;
1508    let token = skip_authorize();
1509    token.authorized_ok(web::Json(privacy_link))
1510}
1511
1512/**
1513GET /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.
1514*/
1515#[utoipa::path(
1516    get,
1517    path = "/{course_id}/custom-privacy-policy-checkbox-texts",
1518    operation_id = "getCourseMaterialCustomPrivacyPolicyCheckboxTexts",
1519    tag = "course-material-courses",
1520    params(
1521        ("course_id" = Uuid, Path, description = "Course id")
1522    ),
1523    responses(
1524        (status = 200, description = "Custom privacy policy checkbox texts", body = Vec<CourseCustomPrivacyPolicyCheckboxText>)
1525    )
1526)]
1527#[instrument(skip(pool))]
1528async fn get_custom_privacy_policy_checkbox_texts(
1529    course_id: web::Path<Uuid>,
1530    pool: web::Data<PgPool>,
1531    user: AuthUser, // Ensure the user is authenticated
1532) -> ControllerResult<web::Json<Vec<CourseCustomPrivacyPolicyCheckboxText>>> {
1533    let mut conn = pool.acquire().await?;
1534
1535    let token = authorize_access_to_course_material(&mut conn, Some(user.id), *course_id).await?;
1536
1537    let texts = models::course_custom_privacy_policy_checkbox_texts::get_all_by_course_id(
1538        &mut conn, *course_id,
1539    )
1540    .await?;
1541
1542    token.authorized_ok(web::Json(texts))
1543}
1544
1545/**
1546GET `/api/v0/course-material/courses/:course_id/user-chapter-locks` - Get user's chapter locking statuses for course
1547
1548Returns all chapters that the authenticated user has unlocked or completed for the specified course.
1549**/
1550#[utoipa::path(
1551    get,
1552    path = "/{course_id}/user-chapter-locks",
1553    operation_id = "getCourseMaterialUserChapterLocks",
1554    tag = "course-material-courses",
1555    params(
1556        ("course_id" = Uuid, Path, description = "Course id")
1557    ),
1558    responses(
1559        (status = 200, description = "User chapter locking statuses", body = Vec<models::user_chapter_locking_statuses::UserChapterLockingStatus>)
1560    )
1561)]
1562#[instrument(skip(pool))]
1563async fn get_user_chapter_locks(
1564    course_id: web::Path<Uuid>,
1565    pool: web::Data<PgPool>,
1566    user: AuthUser,
1567) -> ControllerResult<web::Json<Vec<models::user_chapter_locking_statuses::UserChapterLockingStatus>>>
1568{
1569    use models::user_chapter_locking_statuses;
1570    let mut conn = pool.acquire().await?;
1571    let token = authorize_access_to_course_material(&mut conn, Some(user.id), *course_id).await?;
1572
1573    let statuses =
1574        user_chapter_locking_statuses::get_or_init_all_for_course(&mut conn, user.id, *course_id)
1575            .await?;
1576
1577    token.authorized_ok(web::Json(statuses))
1578}
1579
1580/**
1581Add a route for each controller in this module.
1582
1583The name starts with an underline in order to appear before other functions in the module documentation.
1584
1585We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
1586*/
1587pub fn _add_routes(cfg: &mut ServiceConfig) {
1588    cfg.route("/{course_id}", web::get().to(get_course))
1589        .route("/{course_id}/chapters", web::get().to(get_chapters))
1590        .route(
1591            "/{course_id}/course-instances",
1592            web::get().to(get_course_instances),
1593        )
1594        .route(
1595            "/{course_id}/current-instance",
1596            web::get().to(get_current_course_instance),
1597        )
1598        .route("/{course_id}/feedback", web::post().to(feedback))
1599        .route(
1600            "/{course_id}/page-by-path/{url_path:.*}",
1601            web::get().to(get_course_page_by_path),
1602        )
1603        .route(
1604            "/{course_id}/search-pages-with-phrase",
1605            web::post().to(search_pages_with_phrase),
1606        )
1607        .route(
1608            "/{course_id}/language-versions-navigation-info/from-page/{page_id}",
1609            web::get().to(get_all_course_language_versions_navigation_info_from_page),
1610        )
1611        .route(
1612            "/{course_id}/search-pages-with-words",
1613            web::post().to(search_pages_with_words),
1614        )
1615        .route(
1616            "/{course_id}/user-settings",
1617            web::get().to(get_user_course_settings),
1618        )
1619        .route(
1620            "/{course_id}/top-level-pages",
1621            web::get().to(get_public_top_level_pages),
1622        )
1623        .route("/{course_id}/propose-edit", web::post().to(propose_edit))
1624        .route("/{course_id}/glossary", web::get().to(glossary))
1625        .route(
1626            "/{course_id}/references",
1627            web::get().to(get_material_references_by_course_id),
1628        )
1629        .route(
1630            "/{course_id}/pages/by-language-group-id/{page_language_group_id}",
1631            web::get().to(get_page_by_course_id_and_language_group),
1632        )
1633        .route("/{course_id}/pages", web::get().to(get_public_course_pages))
1634        .route(
1635            "/{course_id}/course-instances/{course_instance_id}/student-countries/{country_code}",
1636            web::post().to(student_country),
1637        )
1638        .route(
1639            "/{course_instance_id}/student-country",
1640            web::get().to(get_student_country),
1641        )
1642        .route(
1643            "/{course_id}/course-instances/{course_instance_id}/student-countries",
1644            web::get().to(get_student_countries),
1645        )
1646        .route(
1647            "/{course_id}/research-consent-form-questions-answer",
1648            web::post().to(upsert_course_research_form_answer),
1649        )
1650        .route(
1651            "/{courseId}/research-consent-form-user-answers",
1652            web::get().to(get_research_form_answers_with_user_id),
1653        )
1654        .route(
1655            "/{course_id}/research-consent-form",
1656            web::get().to(get_research_form_with_course_id),
1657        )
1658        .route(
1659            "/{course_id}/partners-block",
1660            web::get().to(get_partners_block),
1661        )
1662        .route("/{course_id}/privacy-link", web::get().to(get_privacy_link))
1663        .route(
1664            "/{course_id}/research-consent-form-questions",
1665            web::get().to(get_research_form_questions_with_course_id),
1666        )
1667        .route(
1668            "/{course_id}/user-marketing-consent",
1669            web::post().to(update_marketing_consent),
1670        )
1671        .route(
1672            "/{course_id}/fetch-user-marketing-consent",
1673            web::get().to(fetch_user_marketing_consent),
1674        )
1675        .route(
1676            "/{course_id}/ai-usage-notice-acknowledgement",
1677            web::get().to(get_ai_usage_notice_acknowledgement),
1678        )
1679        .route(
1680            "/{course_id}/ai-usage-notice-acknowledgement",
1681            web::post().to(acknowledge_ai_usage_notice),
1682        )
1683        .route(
1684            "/{course_id}/custom-privacy-policy-checkbox-texts",
1685            web::get().to(get_custom_privacy_policy_checkbox_texts),
1686        )
1687        .route(
1688            "/{course_id}/user-chapter-locks",
1689            web::get().to(get_user_chapter_locks),
1690        );
1691}