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