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