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