headless_lms_server/controllers/course_material/
courses.rs

1//! Controllers for requests starting with `/api/v0/course-material/courses`.
2
3use std::{collections::HashMap, net::IpAddr, path::Path};
4
5use actix_http::header::{self, X_FORWARDED_FOR};
6use actix_web::web::Json;
7use chrono::Utc;
8use futures::{FutureExt, future::OptionFuture};
9use headless_lms_models::courses::{CourseLanguageVersionNavigationInfo, CourseMaterialCourse};
10use headless_lms_models::{
11    course_custom_privacy_policy_checkbox_texts::CourseCustomPrivacyPolicyCheckboxText,
12    marketing_consents::UserMarketingConsent,
13};
14use headless_lms_models::{partner_block::PartnersBlock, privacy_link::PrivacyLink};
15use headless_lms_utils::ip_to_country::IpToCountryMapper;
16use isbot::Bots;
17use models::{
18    chapters::ChapterWithStatus,
19    course_instances::CourseInstance,
20    course_modules::CourseModule,
21    courses::{self, get_nondeleted_course_id_by_slug},
22    feedback,
23    feedback::NewFeedback,
24    glossary::Term,
25    material_references::MaterialReference,
26    page_visit_datum::NewPageVisitDatum,
27    page_visit_datum_daily_visit_hashing_keys::{
28        GenerateAnonymousIdentifierInput, generate_anonymous_identifier,
29    },
30    pages::{CoursePageWithUserData, Page, PageSearchResult, PageVisibility, SearchRequest},
31    proposed_page_edits::{self, NewProposedPageEdits},
32    research_forms::{
33        NewResearchFormQuestionAnswer, ResearchForm, ResearchFormQuestion,
34        ResearchFormQuestionAnswer,
35    },
36    student_countries::StudentCountry,
37    user_course_settings::UserCourseSettings,
38};
39
40use crate::{
41    domain::authorization::{
42        Action, Resource, authorize_access_to_course_material,
43        authorize_with_fetched_list_of_roles, can_user_view_chapter, skip_authorize,
44    },
45    prelude::*,
46};
47
48/**
49GET `/api/v0/course-material/courses/:course_id` - Get course.
50*/
51#[instrument(skip(pool))]
52async fn get_course(
53    course_id: web::Path<Uuid>,
54    pool: web::Data<PgPool>,
55) -> ControllerResult<web::Json<CourseMaterialCourse>> {
56    let mut conn = pool.acquire().await?;
57    let course = models::courses::get_course(&mut conn, *course_id).await?;
58    let token = skip_authorize();
59    token.authorized_ok(web::Json(course.into()))
60}
61
62/**
63GET `/:course_slug/page-by-path/...` - Returns a course page by path
64
65If 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.
66
67# Example
68GET /api/v0/course-material/courses/introduction-to-everything/page-by-path//part-2/hello-world
69*/
70
71#[instrument(skip(pool, ip_to_country_mapper, req, file_store, app_conf))]
72async fn get_course_page_by_path(
73    params: web::Path<(String, String)>,
74    pool: web::Data<PgPool>,
75    user: Option<AuthUser>,
76    ip_to_country_mapper: web::Data<IpToCountryMapper>,
77    req: HttpRequest,
78    file_store: web::Data<dyn FileStore>,
79    app_conf: web::Data<ApplicationConfiguration>,
80) -> ControllerResult<web::Json<CoursePageWithUserData>> {
81    let mut conn = pool.acquire().await?;
82
83    let (course_slug, raw_page_path) = params.into_inner();
84    let path = if raw_page_path.starts_with('/') {
85        raw_page_path
86    } else {
87        format!("/{}", raw_page_path)
88    };
89    let user_id = user.map(|u| u.id);
90    let course_data = get_nondeleted_course_id_by_slug(&mut conn, &course_slug).await?;
91    let page_with_user_data = models::pages::get_page_with_user_data_by_path(
92        &mut conn,
93        user_id,
94        &course_data,
95        &path,
96        file_store.as_ref(),
97        &app_conf,
98    )
99    .await?;
100
101    // Chapters may be closed
102    if !can_user_view_chapter(
103        &mut conn,
104        user_id,
105        page_with_user_data.page.course_id,
106        page_with_user_data.page.chapter_id,
107    )
108    .await?
109    {
110        return Err(ControllerError::new(
111            ControllerErrorType::Unauthorized,
112            "Chapter is not open yet.".to_string(),
113            None,
114        ));
115    }
116
117    let token = authorize_access_to_course_material(
118        &mut conn,
119        user_id,
120        page_with_user_data.page.course_id.ok_or_else(|| {
121            ControllerError::new(
122                ControllerErrorType::NotFound,
123                "Course not found".to_string(),
124                None,
125            )
126        })?,
127    )
128    .await?;
129
130    let temp_request_information =
131        derive_information_from_requester(req, ip_to_country_mapper).await?;
132
133    let RequestInformation {
134        ip,
135        referrer,
136        utm_source,
137        utm_medium,
138        utm_campaign,
139        utm_term,
140        utm_content,
141        country,
142        user_agent,
143        has_bot_user_agent,
144        browser_admits_its_a_bot,
145        browser,
146        browser_version,
147        operating_system,
148        operating_system_version,
149        device_type,
150    } = temp_request_information.data;
151
152    let course_or_exam_id = page_with_user_data
153        .page
154        .course_id
155        .unwrap_or_else(|| page_with_user_data.page.exam_id.unwrap_or_else(Uuid::nil));
156    let anonymous_identifier = generate_anonymous_identifier(
157        &mut conn,
158        GenerateAnonymousIdentifierInput {
159            user_agent,
160            ip_address: ip.map(|ip| ip.to_string()).unwrap_or_default(),
161            course_id: course_or_exam_id,
162        },
163    )
164    .await?;
165
166    models::page_visit_datum::insert(
167        &mut conn,
168        NewPageVisitDatum {
169            course_id: page_with_user_data.page.course_id,
170            page_id: page_with_user_data.page.id,
171            country,
172            browser,
173            browser_version,
174            operating_system,
175            operating_system_version,
176            device_type,
177            referrer,
178            is_bot: has_bot_user_agent || browser_admits_its_a_bot,
179            utm_source,
180            utm_medium,
181            utm_campaign,
182            utm_term,
183            utm_content,
184            anonymous_identifier,
185            exam_id: page_with_user_data.page.exam_id,
186        },
187    )
188    .await?;
189
190    token.authorized_ok(web::Json(page_with_user_data))
191}
192
193struct RequestInformation {
194    ip: Option<IpAddr>,
195    user_agent: String,
196    referrer: Option<String>,
197    utm_source: Option<String>,
198    utm_medium: Option<String>,
199    utm_campaign: Option<String>,
200    utm_term: Option<String>,
201    utm_content: Option<String>,
202    country: Option<String>,
203    has_bot_user_agent: bool,
204    browser_admits_its_a_bot: bool,
205    browser: Option<String>,
206    browser_version: Option<String>,
207    operating_system: Option<String>,
208    operating_system_version: Option<String>,
209    device_type: Option<String>,
210}
211
212/// Used in get_course_page_by_path for path for anonymous visitor counts
213async fn derive_information_from_requester(
214    req: HttpRequest,
215    ip_to_country_mapper: web::Data<IpToCountryMapper>,
216) -> ControllerResult<RequestInformation> {
217    let mut headers = req.headers().clone();
218    let x_real_ip = headers.get("X-Real-IP");
219    let x_forwarded_for = headers.get(X_FORWARDED_FOR);
220    let connection_info = req.connection_info();
221    let peer_address = connection_info.peer_addr();
222    let headers_clone = headers.clone();
223    let user_agent = headers_clone.get(header::USER_AGENT);
224    let bots = Bots::default();
225    let has_bot_user_agent = user_agent
226        .and_then(|ua| ua.to_str().ok())
227        .map(|ua| bots.is_bot(ua))
228        .unwrap_or(true);
229    // If this header is not set, the requester is considered a bot
230    let header_totally_not_a_bot = headers.get("totally-not-a-bot");
231    let browser_admits_its_a_bot = header_totally_not_a_bot.is_none();
232    if has_bot_user_agent || browser_admits_its_a_bot {
233        warn!(
234            ?has_bot_user_agent,
235            ?browser_admits_its_a_bot,
236            ?user_agent,
237            ?header_totally_not_a_bot,
238            "The requester is a bot"
239        )
240    }
241
242    let user_agent_parser = woothee::parser::Parser::new();
243    let parsed_user_agent = user_agent
244        .and_then(|ua| ua.to_str().ok())
245        .and_then(|ua| user_agent_parser.parse(ua));
246
247    let ip: Option<IpAddr> = connection_info
248        .realip_remote_addr()
249        .and_then(|ip| ip.parse::<IpAddr>().ok());
250
251    info!(
252        "Ip {:?}, x_real_ip {:?}, x_forwarded_for {:?}, peer_address {:?}",
253        ip, x_real_ip, x_forwarded_for, peer_address
254    );
255
256    let country = ip
257        .and_then(|ip| ip_to_country_mapper.map_ip_to_country(&ip))
258        .map(|c| c.to_string());
259
260    let utm_tags = headers
261        .remove("utm-tags")
262        .next()
263        .and_then(|utms| String::from_utf8(utms.as_bytes().to_vec()).ok())
264        .and_then(|utms| serde_json::from_str::<serde_json::Value>(&utms).ok())
265        .and_then(|o| o.as_object().cloned());
266
267    let utm_source = utm_tags
268        .clone()
269        .and_then(|mut tags| tags.remove("utm_source"))
270        .and_then(|v| v.as_str().map(|s| s.to_string()));
271
272    let utm_medium = utm_tags
273        .clone()
274        .and_then(|mut tags| tags.remove("utm_medium"))
275        .and_then(|v| v.as_str().map(|s| s.to_string()));
276
277    let utm_campaign = utm_tags
278        .clone()
279        .and_then(|mut tags| tags.remove("utm_campaign"))
280        .and_then(|v| v.as_str().map(|s| s.to_string()));
281
282    let utm_term = utm_tags
283        .clone()
284        .and_then(|mut tags| tags.remove("utm_term"))
285        .and_then(|v| v.as_str().map(|s| s.to_string()));
286
287    let utm_content = utm_tags
288        .and_then(|mut tags| tags.remove("utm_content"))
289        .and_then(|v| v.as_str().map(|s| s.to_string()));
290
291    let referrer = headers
292        .get("Orignal-Referrer")
293        .and_then(|r| r.to_str().ok())
294        .map(|r| r.to_string());
295
296    let browser = parsed_user_agent.as_ref().map(|ua| ua.name.to_string());
297    let browser_version = parsed_user_agent.as_ref().map(|ua| ua.version.to_string());
298    let operating_system = parsed_user_agent.as_ref().map(|ua| ua.os.to_string());
299    let operating_system_version = parsed_user_agent
300        .as_ref()
301        .map(|ua| ua.os_version.to_string());
302    let device_type = parsed_user_agent.as_ref().map(|ua| ua.category.to_string());
303    let token = skip_authorize();
304    token.authorized_ok(RequestInformation {
305        ip,
306        user_agent: user_agent
307            .and_then(|ua| ua.to_str().ok())
308            .unwrap_or_default()
309            .to_string(),
310        referrer,
311        utm_source,
312        utm_medium,
313        utm_campaign,
314        utm_term,
315        utm_content,
316        country,
317        has_bot_user_agent,
318        browser_admits_its_a_bot,
319        browser,
320        browser_version,
321        operating_system,
322        operating_system_version,
323        device_type,
324    })
325}
326
327/**
328GET `/api/v0/course-material/courses/:course_id/current-instance` - Returns the instance of a course for the current user, if there is one.
329*/
330#[instrument(skip(pool))]
331async fn get_current_course_instance(
332    pool: web::Data<PgPool>,
333    course_id: web::Path<Uuid>,
334    user: Option<AuthUser>,
335) -> ControllerResult<web::Json<Option<CourseInstance>>> {
336    let mut conn = pool.acquire().await?;
337    if let Some(user) = user {
338        let instance = models::course_instances::current_course_instance_of_user(
339            &mut conn, user.id, *course_id,
340        )
341        .await?;
342        let token = skip_authorize();
343        token.authorized_ok(web::Json(instance))
344    } else {
345        Err(ControllerError::new(
346            ControllerErrorType::NotFound,
347            "User not found".to_string(),
348            None,
349        ))
350    }
351}
352
353/**
354GET `/api/v0/course-material/courses/:course_id/course-instances` - Returns all course instances for given course id.
355*/
356async fn get_course_instances(
357    pool: web::Data<PgPool>,
358    course_id: web::Path<Uuid>,
359) -> ControllerResult<web::Json<Vec<CourseInstance>>> {
360    let mut conn = pool.acquire().await?;
361    let instances =
362        models::course_instances::get_course_instances_for_course(&mut conn, *course_id).await?;
363    let token = skip_authorize();
364    token.authorized_ok(web::Json(instances))
365}
366
367/**
368GET `/api/v0/course-material/courses/:course_id/pages` - Returns a list of public pages on a course.
369
370Since anyone can access this endpoint, any unlisted pages are omited from these results.
371*/
372#[instrument(skip(pool))]
373async fn get_public_course_pages(
374    course_id: web::Path<Uuid>,
375    pool: web::Data<PgPool>,
376) -> ControllerResult<web::Json<Vec<Page>>> {
377    let mut conn = pool.acquire().await?;
378    let pages: Vec<Page> = models::pages::get_all_by_course_id_and_visibility(
379        &mut conn,
380        *course_id,
381        PageVisibility::Public,
382    )
383    .await?;
384    let token = skip_authorize();
385    token.authorized_ok(web::Json(pages))
386}
387
388#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
389#[cfg_attr(feature = "ts_rs", derive(TS))]
390pub struct ChaptersWithStatus {
391    pub is_previewable: bool,
392    pub modules: Vec<CourseMaterialCourseModule>,
393}
394
395#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
396#[cfg_attr(feature = "ts_rs", derive(TS))]
397pub struct CourseMaterialCourseModule {
398    pub chapters: Vec<ChapterWithStatus>,
399    pub id: Uuid,
400    pub is_default: bool,
401    pub name: Option<String>,
402    pub order_number: i32,
403}
404
405/**
406GET `/api/v0/course-material/courses/:course_id/chapters` - Returns a list of chapters in a course.
407*/
408
409#[instrument(skip(pool, file_store, app_conf))]
410async fn get_chapters(
411    course_id: web::Path<Uuid>,
412    user: Option<AuthUser>,
413    pool: web::Data<PgPool>,
414    file_store: web::Data<dyn FileStore>,
415    app_conf: web::Data<ApplicationConfiguration>,
416) -> ControllerResult<web::Json<ChaptersWithStatus>> {
417    let mut conn = pool.acquire().await?;
418    let is_previewable = OptionFuture::from(user.map(|u| {
419        authorize(&mut conn, Act::Teach, Some(u.id), Res::Course(*course_id)).map(|r| r.ok())
420    }))
421    .await
422    .is_some();
423    let token = skip_authorize();
424    let course_modules = models::course_modules::get_by_course_id(&mut conn, *course_id).await?;
425    let exercise_deadline_overrides =
426        models::chapters::exercise_deadline_overrides_by_chapter_for_course(&mut conn, *course_id)
427            .await?;
428    let chapters = models::chapters::course_chapters(&mut conn, *course_id)
429        .await?
430        .into_iter()
431        .map(|chapter| {
432            let chapter_image_url = chapter
433                .chapter_image_path
434                .as_ref()
435                .map(|path| file_store.get_download_url(Path::new(&path), &app_conf));
436            let exercise_deadline_overrides = exercise_deadline_overrides.get(&chapter.id).copied();
437            ChapterWithStatus::from_database_chapter_timestamp_and_image_url(
438                chapter,
439                Utc::now(),
440                chapter_image_url,
441                exercise_deadline_overrides,
442            )
443        })
444        .collect();
445    let modules = collect_course_modules(course_modules, chapters)?.data;
446    token.authorized_ok(web::Json(ChaptersWithStatus {
447        is_previewable,
448        modules,
449    }))
450}
451
452/// Combines course modules and chapters, consuming them.
453fn collect_course_modules(
454    course_modules: Vec<CourseModule>,
455    chapters: Vec<ChapterWithStatus>,
456) -> ControllerResult<Vec<CourseMaterialCourseModule>> {
457    let mut course_modules: HashMap<Uuid, CourseMaterialCourseModule> = course_modules
458        .into_iter()
459        .map(|course_module| {
460            (
461                course_module.id,
462                CourseMaterialCourseModule {
463                    chapters: vec![],
464                    id: course_module.id,
465                    is_default: course_module.name.is_none(),
466                    name: course_module.name,
467                    order_number: course_module.order_number,
468                },
469            )
470        })
471        .collect();
472    for chapter in chapters {
473        course_modules
474            .get_mut(&chapter.course_module_id)
475            .ok_or_else(|| {
476                ControllerError::new(
477                    ControllerErrorType::InternalServerError,
478                    "Module data mismatch.".to_string(),
479                    None,
480                )
481            })?
482            .chapters
483            .push(chapter);
484    }
485    let token = skip_authorize();
486    token.authorized_ok(course_modules.into_values().collect())
487}
488
489/**
490GET `/api/v0/course-material/courses/:course_id/user-settings` - Returns user settings for the current course.
491*/
492#[instrument(skip(pool))]
493async fn get_user_course_settings(
494    pool: web::Data<PgPool>,
495    course_id: web::Path<Uuid>,
496    user: Option<AuthUser>,
497) -> ControllerResult<web::Json<Option<UserCourseSettings>>> {
498    let mut conn = pool.acquire().await?;
499    if let Some(user) = user {
500        let settings = models::user_course_settings::get_user_course_settings_by_course_id(
501            &mut conn, user.id, *course_id,
502        )
503        .await?;
504        let token = skip_authorize();
505        token.authorized_ok(web::Json(settings))
506    } else {
507        Err(ControllerError::new(
508            ControllerErrorType::NotFound,
509            "User not found".to_string(),
510            None,
511        ))
512    }
513}
514
515/**
516POST `/api/v0/course-material/courses/:course_id/search-pages-with-phrase` - Returns a list of pages given a search query.
517
518Provided words are supposed to appear right after each other in the source document.
519
520# Example
521
522Request:
523
524```http
525POST /api/v0/course-material/courses/1a68e8b0-d151-4c0e-9307-bb154e9d2be1/search-pages-with-phrase HTTP/1.1
526Content-Type: application/json
527
528{
529  "query": "Everything"
530}
531```
532*/
533#[instrument(skip(pool))]
534async fn search_pages_with_phrase(
535    course_id: web::Path<Uuid>,
536    payload: web::Json<SearchRequest>,
537    pool: web::Data<PgPool>,
538) -> ControllerResult<web::Json<Vec<PageSearchResult>>> {
539    let mut conn = pool.acquire().await?;
540    let res =
541        models::pages::get_page_search_results_for_phrase(&mut conn, *course_id, &payload).await?;
542    let token = skip_authorize();
543    token.authorized_ok(web::Json(res))
544}
545
546/**
547POST `/api/v0/course-material/courses/:course_id/search-pages-with-words` - Returns a list of pages given a search query.
548
549Provided words can appear in any order in the source document.
550
551# Example
552
553Request:
554
555```http
556POST /api/v0/course-material/courses/1a68e8b0-d151-4c0e-9307-bb154e9d2be1/search-pages-with-words HTTP/1.1
557Content-Type: application/json
558
559{
560  "query": "Everything"
561}
562```
563*/
564#[instrument(skip(pool))]
565async fn search_pages_with_words(
566    course_id: web::Path<Uuid>,
567    payload: web::Json<SearchRequest>,
568    pool: web::Data<PgPool>,
569) -> ControllerResult<web::Json<Vec<PageSearchResult>>> {
570    let mut conn = pool.acquire().await?;
571    let res =
572        models::pages::get_page_search_results_for_words(&mut conn, *course_id, &payload).await?;
573    let token = skip_authorize();
574    token.authorized_ok(web::Json(res))
575}
576
577/**
578POST `/api/v0/course-material/courses/:course_id/feedback` - Creates new feedback.
579*/
580pub async fn feedback(
581    course_id: web::Path<Uuid>,
582    new_feedback: web::Json<Vec<NewFeedback>>,
583    pool: web::Data<PgPool>,
584    user: Option<AuthUser>,
585) -> ControllerResult<web::Json<Vec<Uuid>>> {
586    let mut conn = pool.acquire().await?;
587    let fs = new_feedback.into_inner();
588    let user_id = user.as_ref().map(|u| u.id);
589
590    // validate
591    for f in &fs {
592        if f.feedback_given.len() > 1000 {
593            return Err(ControllerError::new(
594                ControllerErrorType::BadRequest,
595                "Feedback given too long: max 1000".to_string(),
596                None,
597            ));
598        }
599        if f.related_blocks.len() > 100 {
600            return Err(ControllerError::new(
601                ControllerErrorType::BadRequest,
602                "Too many related blocks: max 100".to_string(),
603                None,
604            ));
605        }
606        for block in &f.related_blocks {
607            if block.text.as_ref().map(|t| t.len()).unwrap_or_default() > 10000 {
608                return Err(ControllerError::new(
609                    ControllerErrorType::BadRequest,
610                    "Block text too long: max 10000".to_string(),
611                    None,
612                ));
613            }
614        }
615    }
616
617    let mut tx = conn.begin().await?;
618    let mut ids = vec![];
619    for f in fs {
620        let id = feedback::insert(&mut tx, PKeyPolicy::Generate, user_id, *course_id, f).await?;
621        ids.push(id);
622    }
623    tx.commit().await?;
624    let token = skip_authorize();
625    token.authorized_ok(web::Json(ids))
626}
627
628/**
629POST `/api/v0/course-material/courses/:course_slug/edit` - Creates a new edit proposal.
630*/
631async fn propose_edit(
632    course_slug: web::Path<String>,
633    edits: web::Json<NewProposedPageEdits>,
634    pool: web::Data<PgPool>,
635    user: Option<AuthUser>,
636) -> ControllerResult<web::Json<Uuid>> {
637    let mut conn = pool.acquire().await?;
638    let course = courses::get_course_by_slug(&mut conn, course_slug.as_str()).await?;
639    let (id, _) = proposed_page_edits::insert(
640        &mut conn,
641        PKeyPolicy::Generate,
642        course.id,
643        user.map(|u| u.id),
644        &edits.into_inner(),
645    )
646    .await?;
647    let token = skip_authorize();
648    token.authorized_ok(web::Json(id))
649}
650
651#[instrument(skip(pool))]
652async fn glossary(
653    pool: web::Data<PgPool>,
654    course_id: web::Path<Uuid>,
655) -> ControllerResult<web::Json<Vec<Term>>> {
656    let mut conn = pool.acquire().await?;
657    let glossary = models::glossary::fetch_for_course(&mut conn, *course_id).await?;
658    let token = skip_authorize();
659    token.authorized_ok(web::Json(glossary))
660}
661
662#[instrument(skip(pool))]
663async fn get_material_references_by_course_id(
664    course_id: web::Path<Uuid>,
665    pool: web::Data<PgPool>,
666    user: Option<AuthUser>,
667) -> ControllerResult<web::Json<Vec<MaterialReference>>> {
668    let mut conn = pool.acquire().await?;
669    let token =
670        authorize_access_to_course_material(&mut conn, user.map(|u| u.id), *course_id).await?;
671    let res =
672        models::material_references::get_references_by_course_id(&mut conn, *course_id).await?;
673
674    token.authorized_ok(web::Json(res))
675}
676
677/**
678GET /api/v0/course-material/courses/:course_id/top-level-pages
679*/
680#[instrument(skip(pool))]
681async fn get_public_top_level_pages(
682    course_id: web::Path<Uuid>,
683    pool: web::Data<PgPool>,
684) -> ControllerResult<web::Json<Vec<Page>>> {
685    let mut conn = pool.acquire().await?;
686    let page = models::pages::get_course_top_level_pages_by_course_id_and_visibility(
687        &mut conn,
688        *course_id,
689        PageVisibility::Public,
690    )
691    .await?;
692    let token = skip_authorize();
693    token.authorized_ok(web::Json(page))
694}
695
696/**
697GET `/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.
698*/
699#[instrument(skip(pool))]
700async fn get_all_course_language_versions_navigation_info_from_page(
701    pool: web::Data<PgPool>,
702    path: web::Path<(Uuid, Uuid)>,
703    user: Option<AuthUser>,
704) -> ControllerResult<web::Json<Vec<CourseLanguageVersionNavigationInfo>>> {
705    let mut conn = pool.acquire().await?;
706    let (course_id, page_id) = path.into_inner();
707    let token = skip_authorize();
708    let course = models::courses::get_course(&mut conn, course_id).await?;
709
710    let unfiltered_language_versions =
711        models::courses::get_all_language_versions_of_course(&mut conn, &course).await?;
712
713    let all_pages_in_same_page_language_group =
714        models::page_language_groups::get_all_pages_in_page_language_group_mapping(
715            &mut conn, page_id,
716        )
717        .await?;
718
719    let mut accessible_courses = unfiltered_language_versions
720        .clone()
721        .into_iter()
722        .filter(|c| !c.is_draft)
723        .collect::<Vec<_>>();
724
725    // If user is logged in, check access if we need to add draft courses
726    if let Some(user_id) = user.map(|u| u.id) {
727        let user_roles = models::roles::get_roles(&mut conn, user_id).await?;
728
729        for course_version in unfiltered_language_versions.iter().filter(|c| c.is_draft) {
730            if authorize_with_fetched_list_of_roles(
731                &mut conn,
732                Action::ViewMaterial,
733                Some(user_id),
734                Resource::Course(course_version.id),
735                &user_roles,
736            )
737            .await
738            .is_ok()
739            {
740                accessible_courses.push(course_version.clone());
741            }
742        }
743    }
744
745    token.authorized_ok(web::Json(
746        accessible_courses
747            .into_iter()
748            .map(|c| {
749                let page_language_group_navigation_info =
750                    all_pages_in_same_page_language_group.get(&CourseOrExamId::Course(c.id));
751                CourseLanguageVersionNavigationInfo::from_course_and_page_info(
752                    &c,
753                    page_language_group_navigation_info,
754                )
755            })
756            .collect(),
757    ))
758}
759
760/**
761GET `/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.
762 */
763#[instrument(skip(pool))]
764async fn get_page_by_course_id_and_language_group(
765    info: web::Path<(Uuid, Uuid)>,
766    pool: web::Data<PgPool>,
767) -> ControllerResult<web::Json<Page>> {
768    let mut conn = pool.acquire().await?;
769    let (course_id, page_language_group_id) = info.into_inner();
770
771    let page: Page = models::pages::get_page_by_course_id_and_language_group(
772        &mut conn,
773        course_id,
774        page_language_group_id,
775    )
776    .await?;
777    let token = skip_authorize();
778    token.authorized_ok(web::Json(page))
779}
780
781/**
782POST `/api/v0/{course_id}/course-instances/{course_instance_id}/student-countries/{country_code}` - Add a new student's country entry.
783*/
784#[instrument(skip(pool))]
785async fn student_country(
786    query: web::Path<(Uuid, Uuid, String)>,
787    pool: web::Data<PgPool>,
788    user: AuthUser,
789) -> ControllerResult<Json<bool>> {
790    let mut conn = pool.acquire().await?;
791    let (course_id, course_instance_id, country_code) = query.into_inner();
792
793    models::student_countries::insert(
794        &mut conn,
795        user.id,
796        course_id,
797        course_instance_id,
798        &country_code,
799    )
800    .await?;
801    let token = skip_authorize();
802
803    token.authorized_ok(Json(true))
804}
805
806/**
807GET `/api/v0/{course_id}/course-instances/{course_instance_id}/student-countries - Returns countries of student registered in a course.
808 */
809#[instrument(skip(pool))]
810async fn get_student_countries(
811    query: web::Path<(Uuid, Uuid)>,
812    pool: web::Data<PgPool>,
813    user: AuthUser,
814) -> ControllerResult<web::Json<HashMap<String, u32>>> {
815    let mut conn = pool.acquire().await?;
816    let token = skip_authorize();
817    let (course_id, course_instance_id) = query.into_inner();
818
819    let country_codes: Vec<String> =
820        models::student_countries::get_countries(&mut conn, course_id, course_instance_id)
821            .await?
822            .into_iter()
823            .map(|c| c.country_code)
824            .collect();
825
826    let mut frequency: HashMap<String, u32> = HashMap::new();
827    for code in country_codes {
828        *frequency.entry(code).or_insert(0) += 1
829    }
830
831    token.authorized_ok(web::Json(frequency))
832}
833
834/**
835GET `/api/v0/{course_id}/student-country - Returns country of a student registered in a course.
836 */
837#[instrument(skip(pool))]
838async fn get_student_country(
839    course_instance_id: web::Path<Uuid>,
840    pool: web::Data<PgPool>,
841    user: AuthUser,
842) -> ControllerResult<web::Json<StudentCountry>> {
843    let mut conn = pool.acquire().await?;
844    let token = skip_authorize();
845    let res = models::student_countries::get_selected_country_by_user_id(
846        &mut conn,
847        user.id,
848        *course_instance_id,
849    )
850    .await?;
851
852    token.authorized_ok(web::Json(res))
853}
854
855/**
856GET `/api/v0/course-material/courses/:course_id/research-consent-form` - Fetches courses research form with course id.
857*/
858#[instrument(skip(pool))]
859async fn get_research_form_with_course_id(
860    course_id: web::Path<Uuid>,
861    user: AuthUser,
862    pool: web::Data<PgPool>,
863) -> ControllerResult<web::Json<Option<ResearchForm>>> {
864    let mut conn = pool.acquire().await?;
865    let user_id = Some(user.id);
866
867    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
868
869    let res = models::research_forms::get_research_form_with_course_id(&mut conn, *course_id)
870        .await
871        .optional()?;
872
873    token.authorized_ok(web::Json(res))
874}
875
876/**
877GET `/api/v0/course-material/courses/:course_id/research-consent-form-questions` - Fetches courses research form questions with course id.
878*/
879#[instrument(skip(pool))]
880async fn get_research_form_questions_with_course_id(
881    course_id: web::Path<Uuid>,
882    user: AuthUser,
883    pool: web::Data<PgPool>,
884) -> ControllerResult<web::Json<Vec<ResearchFormQuestion>>> {
885    let mut conn = pool.acquire().await?;
886    let user_id = Some(user.id);
887
888    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
889    let res =
890        models::research_forms::get_research_form_questions_with_course_id(&mut conn, *course_id)
891            .await?;
892
893    token.authorized_ok(web::Json(res))
894}
895
896/**
897POST `/api/v0/course-material/courses/:course_id/research-consent-form-questions-answer` - Upserts users consent for a courses research form question.
898*/
899
900#[instrument(skip(pool, payload))]
901async fn upsert_course_research_form_answer(
902    payload: web::Json<NewResearchFormQuestionAnswer>,
903    pool: web::Data<PgPool>,
904    course_id: web::Path<Uuid>,
905    user: AuthUser,
906) -> ControllerResult<web::Json<Uuid>> {
907    let mut conn = pool.acquire().await?;
908    let user_id = Some(user.id);
909
910    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
911    let answer = payload;
912    let res =
913        models::research_forms::upsert_research_form_anwser(&mut conn, *course_id, &answer).await?;
914
915    token.authorized_ok(web::Json(res))
916}
917
918/**
919GET `/api/v0/course/courses/:course_id/research-consent-form-users-answers` - Fetches users answers for courses research form.
920*/
921#[instrument(skip(pool))]
922async fn get_research_form_answers_with_user_id(
923    course_id: web::Path<Uuid>,
924    user: AuthUser,
925    pool: web::Data<PgPool>,
926) -> ControllerResult<web::Json<Vec<ResearchFormQuestionAnswer>>> {
927    let mut conn = pool.acquire().await?;
928    let user_id = Some(user.id);
929
930    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
931
932    let res = models::research_forms::get_research_form_answers_with_user_id(
933        &mut conn, *course_id, user.id,
934    )
935    .await?;
936
937    token.authorized_ok(web::Json(res))
938}
939
940#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
941#[cfg_attr(feature = "ts_rs", derive(TS))]
942pub struct UserMarketingConsentPayload {
943    pub course_language_groups_id: Uuid,
944    pub email_subscription: bool,
945    pub marketing_consent: bool,
946}
947
948/**
949POST `/api/v0/course-material/courses/:course_id/user-marketing-consent` - Adds or updates user's marketing consent for a specific course.
950*/
951#[instrument(skip(pool, payload))]
952async fn update_marketing_consent(
953    payload: web::Json<UserMarketingConsentPayload>,
954    pool: web::Data<PgPool>,
955    course_id: web::Path<Uuid>,
956    user: AuthUser,
957) -> ControllerResult<web::Json<Uuid>> {
958    let mut conn = pool.acquire().await?;
959    let user_id = Some(user.id);
960
961    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
962
963    let email_subscription = if payload.email_subscription {
964        "subscribed"
965    } else {
966        "unsubscribed"
967    };
968
969    let result = models::marketing_consents::upsert_marketing_consent(
970        &mut conn,
971        *course_id,
972        payload.course_language_groups_id,
973        &user.id,
974        email_subscription,
975        payload.marketing_consent,
976    )
977    .await?;
978
979    token.authorized_ok(web::Json(result))
980}
981
982/**
983GET `/api/v0/course-material/courses/:course_id/fetch-user-marketing-consent`
984*/
985#[instrument(skip(pool))]
986async fn fetch_user_marketing_consent(
987    pool: web::Data<PgPool>,
988    course_id: web::Path<Uuid>,
989    user: AuthUser,
990) -> ControllerResult<web::Json<Option<UserMarketingConsent>>> {
991    let mut conn = pool.acquire().await?;
992    let user_id = Some(user.id);
993
994    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
995
996    let result =
997        models::marketing_consents::fetch_user_marketing_consent(&mut conn, *course_id, &user.id)
998            .await
999            .ok();
1000
1001    token.authorized_ok(web::Json(result))
1002}
1003
1004/**
1005GET /courses/:course_id/partners-block - Gets a partners block related to a course
1006*/
1007#[instrument(skip(pool))]
1008async fn get_partners_block(
1009    path: web::Path<Uuid>,
1010    pool: web::Data<PgPool>,
1011) -> ControllerResult<web::Json<Option<PartnersBlock>>> {
1012    let course_id = path.into_inner();
1013    let mut conn = pool.acquire().await?;
1014    let partner_block = models::partner_block::get_partner_block(&mut conn, course_id)
1015        .await
1016        .optional()?;
1017    let token = skip_authorize();
1018    token.authorized_ok(web::Json(partner_block))
1019}
1020
1021/**
1022GET /courses/:course_id/privacy_link - Gets a privacy link related to a course
1023*/
1024#[instrument(skip(pool))]
1025async fn get_privacy_link(
1026    course_id: web::Path<Uuid>,
1027    pool: web::Data<PgPool>,
1028) -> ControllerResult<web::Json<Vec<PrivacyLink>>> {
1029    let mut conn = pool.acquire().await?;
1030    let privacy_link = models::privacy_link::get_privacy_link(&mut conn, *course_id).await?;
1031    let token = skip_authorize();
1032    token.authorized_ok(web::Json(privacy_link))
1033}
1034
1035/**
1036GET /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.
1037*/
1038#[instrument(skip(pool))]
1039async fn get_custom_privacy_policy_checkbox_texts(
1040    course_id: web::Path<Uuid>,
1041    pool: web::Data<PgPool>,
1042    user: AuthUser, // Ensure the user is authenticated
1043) -> ControllerResult<web::Json<Vec<CourseCustomPrivacyPolicyCheckboxText>>> {
1044    let mut conn = pool.acquire().await?;
1045
1046    let token = authorize_access_to_course_material(&mut conn, Some(user.id), *course_id).await?;
1047
1048    let texts = models::course_custom_privacy_policy_checkbox_texts::get_all_by_course_id(
1049        &mut conn, *course_id,
1050    )
1051    .await?;
1052
1053    token.authorized_ok(web::Json(texts))
1054}
1055
1056/**
1057GET `/api/v0/course-material/courses/:course_id/user-chapter-locks` - Get user's chapter locking statuses for course
1058
1059Returns all chapters that the authenticated user has unlocked or completed for the specified course.
1060**/
1061#[instrument(skip(pool))]
1062async fn get_user_chapter_locks(
1063    course_id: web::Path<Uuid>,
1064    pool: web::Data<PgPool>,
1065    user: AuthUser,
1066) -> ControllerResult<web::Json<Vec<models::user_chapter_locking_statuses::UserChapterLockingStatus>>>
1067{
1068    use models::user_chapter_locking_statuses;
1069    let mut conn = pool.acquire().await?;
1070    let token = authorize_access_to_course_material(&mut conn, Some(user.id), *course_id).await?;
1071
1072    let statuses =
1073        user_chapter_locking_statuses::get_by_user_and_course(&mut conn, user.id, *course_id)
1074            .await?;
1075
1076    token.authorized_ok(web::Json(statuses))
1077}
1078
1079/**
1080Add a route for each controller in this module.
1081
1082The name starts with an underline in order to appear before other functions in the module documentation.
1083
1084We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
1085*/
1086pub fn _add_routes(cfg: &mut ServiceConfig) {
1087    cfg.route("/{course_id}", web::get().to(get_course))
1088        .route("/{course_id}/chapters", web::get().to(get_chapters))
1089        .route(
1090            "/{course_id}/course-instances",
1091            web::get().to(get_course_instances),
1092        )
1093        .route(
1094            "/{course_id}/current-instance",
1095            web::get().to(get_current_course_instance),
1096        )
1097        .route("/{course_id}/feedback", web::post().to(feedback))
1098        .route(
1099            "/{course_id}/page-by-path/{url_path:.*}",
1100            web::get().to(get_course_page_by_path),
1101        )
1102        .route(
1103            "/{course_id}/search-pages-with-phrase",
1104            web::post().to(search_pages_with_phrase),
1105        )
1106        .route(
1107            "/{course_id}/language-versions-navigation-info/from-page/{page_id}",
1108            web::get().to(get_all_course_language_versions_navigation_info_from_page),
1109        )
1110        .route(
1111            "/{course_id}/search-pages-with-words",
1112            web::post().to(search_pages_with_words),
1113        )
1114        .route(
1115            "/{course_id}/user-settings",
1116            web::get().to(get_user_course_settings),
1117        )
1118        .route(
1119            "/{course_id}/top-level-pages",
1120            web::get().to(get_public_top_level_pages),
1121        )
1122        .route("/{course_id}/propose-edit", web::post().to(propose_edit))
1123        .route("/{course_id}/glossary", web::get().to(glossary))
1124        .route(
1125            "/{course_id}/references",
1126            web::get().to(get_material_references_by_course_id),
1127        )
1128        .route(
1129            "/{course_id}/pages/by-language-group-id/{page_language_group_id}",
1130            web::get().to(get_page_by_course_id_and_language_group),
1131        )
1132        .route("/{course_id}/pages", web::get().to(get_public_course_pages))
1133        .route(
1134            "/{course_id}/course-instances/{course_instance_id}/student-countries/{country_code}",
1135            web::post().to(student_country),
1136        )
1137        .route(
1138            "/{course_instance_id}/student-country",
1139            web::get().to(get_student_country),
1140        )
1141        .route(
1142            "/{course_id}/course-instances/{course_instance_id}/student-countries",
1143            web::get().to(get_student_countries),
1144        )
1145        .route(
1146            "/{course_id}/research-consent-form-questions-answer",
1147            web::post().to(upsert_course_research_form_answer),
1148        )
1149        .route(
1150            "/{courseId}/research-consent-form-user-answers",
1151            web::get().to(get_research_form_answers_with_user_id),
1152        )
1153        .route(
1154            "/{course_id}/research-consent-form",
1155            web::get().to(get_research_form_with_course_id),
1156        )
1157        .route(
1158            "/{course_id}/partners-block",
1159            web::get().to(get_partners_block),
1160        )
1161        .route("/{course_id}/privacy-link", web::get().to(get_privacy_link))
1162        .route(
1163            "/{course_id}/research-consent-form-questions",
1164            web::get().to(get_research_form_questions_with_course_id),
1165        )
1166        .route(
1167            "/{course_id}/user-marketing-consent",
1168            web::post().to(update_marketing_consent),
1169        )
1170        .route(
1171            "/{course_id}/fetch-user-marketing-consent",
1172            web::get().to(fetch_user_marketing_consent),
1173        )
1174        .route(
1175            "/{course_id}/custom-privacy-policy-checkbox-texts",
1176            web::get().to(get_custom_privacy_policy_checkbox_texts),
1177        )
1178        .route(
1179            "/{course_id}/user-chapter-locks",
1180            web::get().to(get_user_chapter_locks),
1181        );
1182}