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 chapters = models::chapters::course_chapters(&mut conn, *course_id)
426        .await?
427        .into_iter()
428        .map(|chapter| {
429            let chapter_image_url = chapter
430                .chapter_image_path
431                .as_ref()
432                .map(|path| file_store.get_download_url(Path::new(&path), &app_conf));
433            ChapterWithStatus::from_database_chapter_timestamp_and_image_url(
434                chapter,
435                Utc::now(),
436                chapter_image_url,
437            )
438        })
439        .collect();
440    let modules = collect_course_modules(course_modules, chapters)?.data;
441    token.authorized_ok(web::Json(ChaptersWithStatus {
442        is_previewable,
443        modules,
444    }))
445}
446
447/// Combines course modules and chapters, consuming them.
448fn collect_course_modules(
449    course_modules: Vec<CourseModule>,
450    chapters: Vec<ChapterWithStatus>,
451) -> ControllerResult<Vec<CourseMaterialCourseModule>> {
452    let mut course_modules: HashMap<Uuid, CourseMaterialCourseModule> = course_modules
453        .into_iter()
454        .map(|course_module| {
455            (
456                course_module.id,
457                CourseMaterialCourseModule {
458                    chapters: vec![],
459                    id: course_module.id,
460                    is_default: course_module.name.is_none(),
461                    name: course_module.name,
462                    order_number: course_module.order_number,
463                },
464            )
465        })
466        .collect();
467    for chapter in chapters {
468        course_modules
469            .get_mut(&chapter.course_module_id)
470            .ok_or_else(|| {
471                ControllerError::new(
472                    ControllerErrorType::InternalServerError,
473                    "Module data mismatch.".to_string(),
474                    None,
475                )
476            })?
477            .chapters
478            .push(chapter);
479    }
480    let token = skip_authorize();
481    token.authorized_ok(course_modules.into_values().collect())
482}
483
484/**
485GET `/api/v0/course-material/courses/:course_id/user-settings` - Returns user settings for the current course.
486*/
487#[instrument(skip(pool))]
488async fn get_user_course_settings(
489    pool: web::Data<PgPool>,
490    course_id: web::Path<Uuid>,
491    user: Option<AuthUser>,
492) -> ControllerResult<web::Json<Option<UserCourseSettings>>> {
493    let mut conn = pool.acquire().await?;
494    if let Some(user) = user {
495        let settings = models::user_course_settings::get_user_course_settings_by_course_id(
496            &mut conn, user.id, *course_id,
497        )
498        .await?;
499        let token = skip_authorize();
500        token.authorized_ok(web::Json(settings))
501    } else {
502        Err(ControllerError::new(
503            ControllerErrorType::NotFound,
504            "User not found".to_string(),
505            None,
506        ))
507    }
508}
509
510/**
511POST `/api/v0/course-material/courses/:course_id/search-pages-with-phrase` - Returns a list of pages given a search query.
512
513Provided words are supposed to appear right after each other in the source document.
514
515# Example
516
517Request:
518
519```http
520POST /api/v0/course-material/courses/1a68e8b0-d151-4c0e-9307-bb154e9d2be1/search-pages-with-phrase HTTP/1.1
521Content-Type: application/json
522
523{
524  "query": "Everything"
525}
526```
527*/
528#[instrument(skip(pool))]
529async fn search_pages_with_phrase(
530    course_id: web::Path<Uuid>,
531    payload: web::Json<SearchRequest>,
532    pool: web::Data<PgPool>,
533) -> ControllerResult<web::Json<Vec<PageSearchResult>>> {
534    let mut conn = pool.acquire().await?;
535    let res =
536        models::pages::get_page_search_results_for_phrase(&mut conn, *course_id, &payload).await?;
537    let token = skip_authorize();
538    token.authorized_ok(web::Json(res))
539}
540
541/**
542POST `/api/v0/course-material/courses/:course_id/search-pages-with-words` - Returns a list of pages given a search query.
543
544Provided words can appear in any order in the source document.
545
546# Example
547
548Request:
549
550```http
551POST /api/v0/course-material/courses/1a68e8b0-d151-4c0e-9307-bb154e9d2be1/search-pages-with-words HTTP/1.1
552Content-Type: application/json
553
554{
555  "query": "Everything"
556}
557```
558*/
559#[instrument(skip(pool))]
560async fn search_pages_with_words(
561    course_id: web::Path<Uuid>,
562    payload: web::Json<SearchRequest>,
563    pool: web::Data<PgPool>,
564) -> ControllerResult<web::Json<Vec<PageSearchResult>>> {
565    let mut conn = pool.acquire().await?;
566    let res =
567        models::pages::get_page_search_results_for_words(&mut conn, *course_id, &payload).await?;
568    let token = skip_authorize();
569    token.authorized_ok(web::Json(res))
570}
571
572/**
573POST `/api/v0/course-material/courses/:course_id/feedback` - Creates new feedback.
574*/
575pub async fn feedback(
576    course_id: web::Path<Uuid>,
577    new_feedback: web::Json<Vec<NewFeedback>>,
578    pool: web::Data<PgPool>,
579    user: Option<AuthUser>,
580) -> ControllerResult<web::Json<Vec<Uuid>>> {
581    let mut conn = pool.acquire().await?;
582    let fs = new_feedback.into_inner();
583    let user_id = user.as_ref().map(|u| u.id);
584
585    // validate
586    for f in &fs {
587        if f.feedback_given.len() > 1000 {
588            return Err(ControllerError::new(
589                ControllerErrorType::BadRequest,
590                "Feedback given too long: max 1000".to_string(),
591                None,
592            ));
593        }
594        if f.related_blocks.len() > 100 {
595            return Err(ControllerError::new(
596                ControllerErrorType::BadRequest,
597                "Too many related blocks: max 100".to_string(),
598                None,
599            ));
600        }
601        for block in &f.related_blocks {
602            if block.text.as_ref().map(|t| t.len()).unwrap_or_default() > 10000 {
603                return Err(ControllerError::new(
604                    ControllerErrorType::BadRequest,
605                    "Block text too long: max 10000".to_string(),
606                    None,
607                ));
608            }
609        }
610    }
611
612    let mut tx = conn.begin().await?;
613    let mut ids = vec![];
614    for f in fs {
615        let id = feedback::insert(&mut tx, PKeyPolicy::Generate, user_id, *course_id, f).await?;
616        ids.push(id);
617    }
618    tx.commit().await?;
619    let token = skip_authorize();
620    token.authorized_ok(web::Json(ids))
621}
622
623/**
624POST `/api/v0/course-material/courses/:course_slug/edit` - Creates a new edit proposal.
625*/
626async fn propose_edit(
627    course_slug: web::Path<String>,
628    edits: web::Json<NewProposedPageEdits>,
629    pool: web::Data<PgPool>,
630    user: Option<AuthUser>,
631) -> ControllerResult<web::Json<Uuid>> {
632    let mut conn = pool.acquire().await?;
633    let course = courses::get_course_by_slug(&mut conn, course_slug.as_str()).await?;
634    let (id, _) = proposed_page_edits::insert(
635        &mut conn,
636        PKeyPolicy::Generate,
637        course.id,
638        user.map(|u| u.id),
639        &edits.into_inner(),
640    )
641    .await?;
642    let token = skip_authorize();
643    token.authorized_ok(web::Json(id))
644}
645
646#[instrument(skip(pool))]
647async fn glossary(
648    pool: web::Data<PgPool>,
649    course_id: web::Path<Uuid>,
650) -> ControllerResult<web::Json<Vec<Term>>> {
651    let mut conn = pool.acquire().await?;
652    let glossary = models::glossary::fetch_for_course(&mut conn, *course_id).await?;
653    let token = skip_authorize();
654    token.authorized_ok(web::Json(glossary))
655}
656
657#[instrument(skip(pool))]
658async fn get_material_references_by_course_id(
659    course_id: web::Path<Uuid>,
660    pool: web::Data<PgPool>,
661    user: Option<AuthUser>,
662) -> ControllerResult<web::Json<Vec<MaterialReference>>> {
663    let mut conn = pool.acquire().await?;
664    let token =
665        authorize_access_to_course_material(&mut conn, user.map(|u| u.id), *course_id).await?;
666    let res =
667        models::material_references::get_references_by_course_id(&mut conn, *course_id).await?;
668
669    token.authorized_ok(web::Json(res))
670}
671
672/**
673GET /api/v0/course-material/courses/:course_id/top-level-pages
674*/
675#[instrument(skip(pool))]
676async fn get_public_top_level_pages(
677    course_id: web::Path<Uuid>,
678    pool: web::Data<PgPool>,
679) -> ControllerResult<web::Json<Vec<Page>>> {
680    let mut conn = pool.acquire().await?;
681    let page = models::pages::get_course_top_level_pages_by_course_id_and_visibility(
682        &mut conn,
683        *course_id,
684        PageVisibility::Public,
685    )
686    .await?;
687    let token = skip_authorize();
688    token.authorized_ok(web::Json(page))
689}
690
691/**
692GET `/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.
693*/
694#[instrument(skip(pool))]
695async fn get_all_course_language_versions_navigation_info_from_page(
696    pool: web::Data<PgPool>,
697    path: web::Path<(Uuid, Uuid)>,
698    user: Option<AuthUser>,
699) -> ControllerResult<web::Json<Vec<CourseLanguageVersionNavigationInfo>>> {
700    let mut conn = pool.acquire().await?;
701    let (course_id, page_id) = path.into_inner();
702    let token = skip_authorize();
703    let course = models::courses::get_course(&mut conn, course_id).await?;
704
705    let unfiltered_language_versions =
706        models::courses::get_all_language_versions_of_course(&mut conn, &course).await?;
707
708    let all_pages_in_same_page_language_group =
709        models::page_language_groups::get_all_pages_in_page_language_group_mapping(
710            &mut conn, page_id,
711        )
712        .await?;
713
714    let mut accessible_courses = unfiltered_language_versions
715        .clone()
716        .into_iter()
717        .filter(|c| !c.is_draft)
718        .collect::<Vec<_>>();
719
720    // If user is logged in, check access if we need to add draft courses
721    if let Some(user_id) = user.map(|u| u.id) {
722        let user_roles = models::roles::get_roles(&mut conn, user_id).await?;
723
724        for course_version in unfiltered_language_versions.iter().filter(|c| c.is_draft) {
725            if authorize_with_fetched_list_of_roles(
726                &mut conn,
727                Action::ViewMaterial,
728                Some(user_id),
729                Resource::Course(course_version.id),
730                &user_roles,
731            )
732            .await
733            .is_ok()
734            {
735                accessible_courses.push(course_version.clone());
736            }
737        }
738    }
739
740    token.authorized_ok(web::Json(
741        accessible_courses
742            .into_iter()
743            .map(|c| {
744                let page_language_group_navigation_info =
745                    all_pages_in_same_page_language_group.get(&CourseOrExamId::Course(c.id));
746                CourseLanguageVersionNavigationInfo::from_course_and_page_info(
747                    &c,
748                    page_language_group_navigation_info,
749                )
750            })
751            .collect(),
752    ))
753}
754
755/**
756GET `/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.
757 */
758#[instrument(skip(pool))]
759async fn get_page_by_course_id_and_language_group(
760    info: web::Path<(Uuid, Uuid)>,
761    pool: web::Data<PgPool>,
762) -> ControllerResult<web::Json<Page>> {
763    let mut conn = pool.acquire().await?;
764    let (course_id, page_language_group_id) = info.into_inner();
765
766    let page: Page = models::pages::get_page_by_course_id_and_language_group(
767        &mut conn,
768        course_id,
769        page_language_group_id,
770    )
771    .await?;
772    let token = skip_authorize();
773    token.authorized_ok(web::Json(page))
774}
775
776/**
777POST `/api/v0/{course_id}/course-instances/{course_instance_id}/student-countries/{country_code}` - Add a new student's country entry.
778*/
779#[instrument(skip(pool))]
780async fn student_country(
781    query: web::Path<(Uuid, Uuid, String)>,
782    pool: web::Data<PgPool>,
783    user: AuthUser,
784) -> ControllerResult<Json<bool>> {
785    let mut conn = pool.acquire().await?;
786    let (course_id, course_instance_id, country_code) = query.into_inner();
787
788    models::student_countries::insert(
789        &mut conn,
790        user.id,
791        course_id,
792        course_instance_id,
793        &country_code,
794    )
795    .await?;
796    let token = skip_authorize();
797
798    token.authorized_ok(Json(true))
799}
800
801/**
802GET `/api/v0/{course_id}/course-instances/{course_instance_id}/student-countries - Returns countries of student registered in a course.
803 */
804#[instrument(skip(pool))]
805async fn get_student_countries(
806    query: web::Path<(Uuid, Uuid)>,
807    pool: web::Data<PgPool>,
808    user: AuthUser,
809) -> ControllerResult<web::Json<HashMap<String, u32>>> {
810    let mut conn = pool.acquire().await?;
811    let token = skip_authorize();
812    let (course_id, course_instance_id) = query.into_inner();
813
814    let country_codes: Vec<String> =
815        models::student_countries::get_countries(&mut conn, course_id, course_instance_id)
816            .await?
817            .into_iter()
818            .map(|c| (c.country_code))
819            .collect();
820
821    let mut frequency: HashMap<String, u32> = HashMap::new();
822    for code in country_codes {
823        *frequency.entry(code).or_insert(0) += 1
824    }
825
826    token.authorized_ok(web::Json(frequency))
827}
828
829/**
830GET `/api/v0/{course_id}/student-country - Returns country of a student registered in a course.
831 */
832#[instrument(skip(pool))]
833async fn get_student_country(
834    course_instance_id: web::Path<Uuid>,
835    pool: web::Data<PgPool>,
836    user: AuthUser,
837) -> ControllerResult<web::Json<StudentCountry>> {
838    let mut conn = pool.acquire().await?;
839    let token = skip_authorize();
840    let res = models::student_countries::get_selected_country_by_user_id(
841        &mut conn,
842        user.id,
843        *course_instance_id,
844    )
845    .await?;
846
847    token.authorized_ok(web::Json(res))
848}
849
850/**
851GET `/api/v0/course-material/courses/:course_id/research-consent-form` - Fetches courses research form with course id.
852*/
853#[instrument(skip(pool))]
854async fn get_research_form_with_course_id(
855    course_id: web::Path<Uuid>,
856    user: AuthUser,
857    pool: web::Data<PgPool>,
858) -> ControllerResult<web::Json<Option<ResearchForm>>> {
859    let mut conn = pool.acquire().await?;
860    let user_id = Some(user.id);
861
862    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
863
864    let res = models::research_forms::get_research_form_with_course_id(&mut conn, *course_id)
865        .await
866        .optional()?;
867
868    token.authorized_ok(web::Json(res))
869}
870
871/**
872GET `/api/v0/course-material/courses/:course_id/research-consent-form-questions` - Fetches courses research form questions with course id.
873*/
874#[instrument(skip(pool))]
875async fn get_research_form_questions_with_course_id(
876    course_id: web::Path<Uuid>,
877    user: AuthUser,
878    pool: web::Data<PgPool>,
879) -> ControllerResult<web::Json<Vec<ResearchFormQuestion>>> {
880    let mut conn = pool.acquire().await?;
881    let user_id = Some(user.id);
882
883    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
884    let res =
885        models::research_forms::get_research_form_questions_with_course_id(&mut conn, *course_id)
886            .await?;
887
888    token.authorized_ok(web::Json(res))
889}
890
891/**
892POST `/api/v0/course-material/courses/:course_id/research-consent-form-questions-answer` - Upserts users consent for a courses research form question.
893*/
894
895#[instrument(skip(pool, payload))]
896async fn upsert_course_research_form_answer(
897    payload: web::Json<NewResearchFormQuestionAnswer>,
898    pool: web::Data<PgPool>,
899    course_id: web::Path<Uuid>,
900    user: AuthUser,
901) -> ControllerResult<web::Json<Uuid>> {
902    let mut conn = pool.acquire().await?;
903    let user_id = Some(user.id);
904
905    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
906    let answer = payload;
907    let res =
908        models::research_forms::upsert_research_form_anwser(&mut conn, *course_id, &answer).await?;
909
910    token.authorized_ok(web::Json(res))
911}
912
913/**
914GET `/api/v0/course/courses/:course_id/research-consent-form-users-answers` - Fetches users answers for courses research form.
915*/
916#[instrument(skip(pool))]
917async fn get_research_form_answers_with_user_id(
918    course_id: web::Path<Uuid>,
919    user: AuthUser,
920    pool: web::Data<PgPool>,
921) -> ControllerResult<web::Json<Vec<ResearchFormQuestionAnswer>>> {
922    let mut conn = pool.acquire().await?;
923    let user_id = Some(user.id);
924
925    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
926
927    let res = models::research_forms::get_research_form_answers_with_user_id(
928        &mut conn, *course_id, user.id,
929    )
930    .await?;
931
932    token.authorized_ok(web::Json(res))
933}
934
935#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
936#[cfg_attr(feature = "ts_rs", derive(TS))]
937pub struct UserMarketingConsentPayload {
938    pub course_language_groups_id: Uuid,
939    pub email_subscription: bool,
940    pub marketing_consent: bool,
941}
942
943/**
944POST `/api/v0/course-material/courses/:course_id/user-marketing-consent` - Adds or updates user's marketing consent for a specific course.
945*/
946#[instrument(skip(pool, payload))]
947async fn update_marketing_consent(
948    payload: web::Json<UserMarketingConsentPayload>,
949    pool: web::Data<PgPool>,
950    course_id: web::Path<Uuid>,
951    user: AuthUser,
952) -> ControllerResult<web::Json<Uuid>> {
953    let mut conn = pool.acquire().await?;
954    let user_id = Some(user.id);
955
956    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
957
958    let email_subscription = if payload.email_subscription {
959        "subscribed"
960    } else {
961        "unsubscribed"
962    };
963
964    let result = models::marketing_consents::upsert_marketing_consent(
965        &mut conn,
966        *course_id,
967        payload.course_language_groups_id,
968        &user.id,
969        email_subscription,
970        payload.marketing_consent,
971    )
972    .await?;
973
974    token.authorized_ok(web::Json(result))
975}
976
977/**
978GET `/api/v0/course-material/courses/:course_id/fetch-user-marketing-consent`
979*/
980#[instrument(skip(pool))]
981async fn fetch_user_marketing_consent(
982    pool: web::Data<PgPool>,
983    course_id: web::Path<Uuid>,
984    user: AuthUser,
985) -> ControllerResult<web::Json<Option<UserMarketingConsent>>> {
986    let mut conn = pool.acquire().await?;
987    let user_id = Some(user.id);
988
989    let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?;
990
991    let result =
992        models::marketing_consents::fetch_user_marketing_consent(&mut conn, *course_id, &user.id)
993            .await
994            .ok();
995
996    token.authorized_ok(web::Json(result))
997}
998
999/**
1000GET /courses/:course_id/partners-block - Gets a partners block related to a course
1001*/
1002#[instrument(skip(pool))]
1003async fn get_partners_block(
1004    path: web::Path<Uuid>,
1005    pool: web::Data<PgPool>,
1006) -> ControllerResult<web::Json<Option<PartnersBlock>>> {
1007    let course_id = path.into_inner();
1008    let mut conn = pool.acquire().await?;
1009    let partner_block = models::partner_block::get_partner_block(&mut conn, course_id)
1010        .await
1011        .optional()?;
1012    let token = skip_authorize();
1013    token.authorized_ok(web::Json(partner_block))
1014}
1015
1016/**
1017GET /courses/:course_id/privacy_link - Gets a privacy link related to a course
1018*/
1019#[instrument(skip(pool))]
1020async fn get_privacy_link(
1021    course_id: web::Path<Uuid>,
1022    pool: web::Data<PgPool>,
1023) -> ControllerResult<web::Json<Vec<PrivacyLink>>> {
1024    let mut conn = pool.acquire().await?;
1025    let privacy_link = models::privacy_link::get_privacy_link(&mut conn, *course_id).await?;
1026    let token = skip_authorize();
1027    token.authorized_ok(web::Json(privacy_link))
1028}
1029
1030/**
1031GET /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.
1032*/
1033#[instrument(skip(pool))]
1034async fn get_custom_privacy_policy_checkbox_texts(
1035    course_id: web::Path<Uuid>,
1036    pool: web::Data<PgPool>,
1037    user: AuthUser, // Ensure the user is authenticated
1038) -> ControllerResult<web::Json<Vec<CourseCustomPrivacyPolicyCheckboxText>>> {
1039    let mut conn = pool.acquire().await?;
1040
1041    let token = authorize_access_to_course_material(&mut conn, Some(user.id), *course_id).await?;
1042
1043    let texts = models::course_custom_privacy_policy_checkbox_texts::get_all_by_course_id(
1044        &mut conn, *course_id,
1045    )
1046    .await?;
1047
1048    token.authorized_ok(web::Json(texts))
1049}
1050
1051/**
1052Add a route for each controller in this module.
1053
1054The name starts with an underline in order to appear before other functions in the module documentation.
1055
1056We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
1057*/
1058pub fn _add_routes(cfg: &mut ServiceConfig) {
1059    cfg.route("/{course_id}", web::get().to(get_course))
1060        .route("/{course_id}/chapters", web::get().to(get_chapters))
1061        .route(
1062            "/{course_id}/course-instances",
1063            web::get().to(get_course_instances),
1064        )
1065        .route(
1066            "/{course_id}/current-instance",
1067            web::get().to(get_current_course_instance),
1068        )
1069        .route("/{course_id}/feedback", web::post().to(feedback))
1070        .route(
1071            "/{course_id}/page-by-path/{url_path:.*}",
1072            web::get().to(get_course_page_by_path),
1073        )
1074        .route(
1075            "/{course_id}/search-pages-with-phrase",
1076            web::post().to(search_pages_with_phrase),
1077        )
1078        .route(
1079            "/{course_id}/language-versions-navigation-info/from-page/{page_id}",
1080            web::get().to(get_all_course_language_versions_navigation_info_from_page),
1081        )
1082        .route(
1083            "/{course_id}/search-pages-with-words",
1084            web::post().to(search_pages_with_words),
1085        )
1086        .route(
1087            "/{course_id}/user-settings",
1088            web::get().to(get_user_course_settings),
1089        )
1090        .route(
1091            "/{course_id}/top-level-pages",
1092            web::get().to(get_public_top_level_pages),
1093        )
1094        .route("/{course_id}/propose-edit", web::post().to(propose_edit))
1095        .route("/{course_id}/glossary", web::get().to(glossary))
1096        .route(
1097            "/{course_id}/references",
1098            web::get().to(get_material_references_by_course_id),
1099        )
1100        .route(
1101            "/{course_id}/pages/by-language-group-id/{page_language_group_id}",
1102            web::get().to(get_page_by_course_id_and_language_group),
1103        )
1104        .route("/{course_id}/pages", web::get().to(get_public_course_pages))
1105        .route(
1106            "/{course_id}/course-instances/{course_instance_id}/student-countries/{country_code}",
1107            web::post().to(student_country),
1108        )
1109        .route(
1110            "/{course_instance_id}/student-country",
1111            web::get().to(get_student_country),
1112        )
1113        .route(
1114            "/{course_id}/course-instances/{course_instance_id}/student-countries",
1115            web::get().to(get_student_countries),
1116        )
1117        .route(
1118            "/{course_id}/research-consent-form-questions-answer",
1119            web::post().to(upsert_course_research_form_answer),
1120        )
1121        .route(
1122            "/{courseId}/research-consent-form-user-answers",
1123            web::get().to(get_research_form_answers_with_user_id),
1124        )
1125        .route(
1126            "/{course_id}/research-consent-form",
1127            web::get().to(get_research_form_with_course_id),
1128        )
1129        .route(
1130            "/{course_id}/partners-block",
1131            web::get().to(get_partners_block),
1132        )
1133        .route("/{course_id}/privacy-link", web::get().to(get_privacy_link))
1134        .route(
1135            "/{course_id}/research-consent-form-questions",
1136            web::get().to(get_research_form_questions_with_course_id),
1137        )
1138        .route(
1139            "/{course_id}/user-marketing-consent",
1140            web::post().to(update_marketing_consent),
1141        )
1142        .route(
1143            "/{course_id}/fetch-user-marketing-consent",
1144            web::get().to(fetch_user_marketing_consent),
1145        )
1146        .route(
1147            "/{course_id}/custom-privacy-policy-checkbox-texts",
1148            web::get().to(get_custom_privacy_policy_checkbox_texts),
1149        );
1150}