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