1use std::collections::HashMap;
2
3use crate::{
4 chapters::{Chapter, get_course_chapters},
5 course_audiences::{CourseAudience, EditCourseAudience, upsert_course_audiences},
6 course_instances::CourseInstance,
7 course_modules::{CourseAuditingModuleUpdate, CourseModule},
8 course_prerequisites::{
9 CoursePrerequisite, EditCoursePrerequisite, upsert_course_prerequisites,
10 },
11 organizations::DatabaseOrganization,
12 pages::{Page, PageVisibility, get_all_by_course_id_and_visibility},
13 prelude::*,
14};
15use headless_lms_utils::{
16 azure_embedding::create_embeddings, file_store::FileStore,
17 language_tag_to_name::LANGUAGE_TAG_TO_NAME, strings::non_empty_trimmed,
18};
19use itertools::multiunzip;
20use pgvector::Vector;
21use utoipa::ToSchema;
22pub struct CourseInfo {
23 pub id: Uuid,
24 pub is_draft: bool,
25}
26
27#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
28
29pub struct CourseCount {
30 pub count: u32,
31}
32
33pub struct CourseContextData {
34 pub id: Uuid,
35 pub is_test_mode: bool,
36}
37
38#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
39pub struct CourseMetadata {
40 course_description: Option<String>,
41 course_audiences: Vec<CourseAudience>,
42 course_prerequisites: Vec<CoursePrerequisite>,
43 course_updated_at: DateTime<Utc>,
44}
45
46#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
47pub struct CourseMetadataUpdate {
48 course_description: Option<String>,
49 course_audiences: Vec<EditCourseAudience>,
50 course_prerequisites: Vec<EditCoursePrerequisite>,
51}
52
53#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
54pub struct CompleteCourseMetadata {
55 course: Course,
56 course_instances: Vec<CourseInstance>,
57 default_module: CourseModule,
58 course_prerequisites: Vec<CoursePrerequisite>,
59 course_audiences: Vec<CourseAudience>,
60 course_organization: DatabaseOrganization,
61}
62
63#[derive(
66 Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default, sqlx::Type, ToSchema,
67)]
68#[sqlx(type_name = "course_ai_policy", rename_all = "snake_case")]
69pub enum CourseAiPolicy {
70 #[default]
72 NotSet,
73 NoAi,
75 PlanningOnly,
77 Limited,
79 FullUse,
81 Required,
83}
84
85#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
86
87pub struct Course {
88 pub id: Uuid,
89 pub slug: String,
90 pub created_at: DateTime<Utc>,
91 pub updated_at: DateTime<Utc>,
92 pub name: String,
93 pub description: Option<String>,
94 pub organization_id: Uuid,
95 pub deleted_at: Option<DateTime<Utc>>,
96 pub language_code: String,
97 pub copied_from: Option<Uuid>,
98 pub content_search_language: Option<String>,
99 pub course_language_group_id: Uuid,
100 pub is_draft: bool,
101 pub is_test_mode: bool,
102 pub is_unlisted: bool,
103 pub base_module_completion_requires_n_submodule_completions: i32,
104 pub can_add_chatbot: bool,
105 pub is_joinable_by_code_only: bool,
106 pub join_code: Option<String>,
107 pub ask_marketing_consent: bool,
108 pub flagged_answers_threshold: Option<i32>,
109 pub flagged_answers_skip_manual_review_and_allow_retry: bool,
110 pub closed_at: Option<DateTime<Utc>>,
111 pub closed_additional_message: Option<String>,
112 pub closed_course_successor_id: Option<Uuid>,
113 pub chapter_locking_enabled: bool,
114 pub cheater_detection_enabled: bool,
115 pub ai_policy: CourseAiPolicy,
116 pub course_material_ai_instructions: Option<bool>,
117}
118
119#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
121
122pub struct CourseMaterialCourse {
123 pub id: Uuid,
124 pub slug: String,
125 pub name: String,
126 pub description: Option<String>,
127 pub organization_id: Uuid,
128 pub language_code: String,
129 pub copied_from: Option<Uuid>,
130 pub content_search_language: Option<String>,
131 pub course_language_group_id: Uuid,
132 pub is_draft: bool,
133 pub is_test_mode: bool,
134 pub is_unlisted: bool,
135 pub base_module_completion_requires_n_submodule_completions: i32,
136 pub is_joinable_by_code_only: bool,
137 pub ask_marketing_consent: bool,
138 pub closed_at: Option<DateTime<Utc>>,
139 pub closed_additional_message: Option<String>,
140 pub closed_course_successor_id: Option<Uuid>,
141 pub chapter_locking_enabled: bool,
142 pub ai_policy: CourseAiPolicy,
143 pub course_material_ai_instructions: Option<bool>,
144}
145
146impl From<Course> for CourseMaterialCourse {
147 fn from(course: Course) -> Self {
148 CourseMaterialCourse {
149 id: course.id,
150 slug: course.slug,
151 name: course.name,
152 description: course.description,
153 organization_id: course.organization_id,
154 language_code: course.language_code,
155 copied_from: course.copied_from,
156 content_search_language: course.content_search_language,
157 course_language_group_id: course.course_language_group_id,
158 is_draft: course.is_draft,
159 is_test_mode: course.is_test_mode,
160 is_unlisted: course.is_unlisted,
161 base_module_completion_requires_n_submodule_completions: course
162 .base_module_completion_requires_n_submodule_completions,
163 is_joinable_by_code_only: course.is_joinable_by_code_only,
164 ask_marketing_consent: course.ask_marketing_consent,
165 closed_at: course.closed_at,
166 closed_additional_message: course.closed_additional_message,
167 closed_course_successor_id: course.closed_course_successor_id,
168 chapter_locking_enabled: course.chapter_locking_enabled,
169 ai_policy: course.ai_policy,
170 course_material_ai_instructions: course.course_material_ai_instructions,
171 }
172 }
173}
174
175#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
177
178pub struct CourseLanguageVersionNavigationInfo {
179 pub course_language_group_id: Uuid,
180 pub course_id: Uuid,
181 pub language_code: String,
182 pub course_slug: String,
183 pub page_path: String,
184 pub is_draft: bool,
185 pub current_page_unavailable_in_this_language: bool,
186}
187
188impl CourseLanguageVersionNavigationInfo {
189 pub fn from_course_and_page_info(
191 course: &Course,
192 page_info: Option<&crate::page_language_groups::PageLanguageGroupNavigationInfo>,
193 ) -> Self {
194 Self {
195 course_language_group_id: course.course_language_group_id,
196 course_id: course.id,
197 language_code: course.language_code.clone(),
198 course_slug: course.slug.clone(),
199 page_path: page_info
200 .map(|p| p.page_path.clone())
201 .unwrap_or_else(|| "/".to_string()),
202 is_draft: course.is_draft,
203 current_page_unavailable_in_this_language: page_info.is_none(),
204 }
205 }
206}
207
208#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
209
210pub struct CourseBreadcrumbInfo {
211 pub course_id: Uuid,
212 pub course_name: String,
213 pub course_slug: String,
214 pub organization_slug: String,
215 pub organization_name: String,
216}
217
218#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
220
221pub struct NewCourse {
222 pub name: String,
223 pub slug: String,
224 pub organization_id: Uuid,
225 pub language_code: String,
226 pub teacher_in_charge_name: String,
228 pub teacher_in_charge_email: String,
230 pub description: String,
231 pub is_draft: bool,
232 pub is_test_mode: bool,
233 pub is_unlisted: bool,
234 pub copy_user_permissions: bool,
236 pub is_joinable_by_code_only: bool,
237 pub join_code: Option<String>,
238 pub ask_marketing_consent: bool,
239 pub flagged_answers_threshold: Option<i32>,
240 pub can_add_chatbot: bool,
241}
242
243#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
244
245pub struct CourseAuditingData {
246 pub id: Uuid,
247 pub name: String,
248 pub slug: String,
249 pub created_at: DateTime<Utc>,
250 pub updated_at: DateTime<Utc>,
251 pub description: Option<String>,
252 pub is_draft: bool,
253 pub is_test_mode: bool,
254 pub is_unlisted: bool,
255 pub is_joinable_by_code_only: bool,
256 pub closed_at: Option<DateTime<Utc>>,
257 pub closed_additional_message: Option<String>,
258 pub closed_course_successor_id: Option<Uuid>,
259 pub organization_id: Uuid,
260 pub organization_name: String,
261 pub organization_slug: String,
262 pub prerequisites: Vec<EditCoursePrerequisite>,
263 pub audiences: Vec<EditCourseAudience>,
264 pub modules: Vec<CourseModule>,
265}
266
267#[derive(Debug, Deserialize, ToSchema)]
268
269pub struct CourseAuditingDataUpdate {
270 pub description: Option<String>,
271 pub is_draft: bool,
272 pub is_test_mode: bool,
273 pub is_unlisted: bool,
274 pub is_joinable_by_code_only: bool,
275 pub closed_at: Option<DateTime<Utc>>,
276 pub closed_additional_message: Option<String>,
277 pub closed_course_successor_id: Option<Uuid>,
278 pub prerequisites: Vec<EditCoursePrerequisite>,
279 pub audiences: Vec<EditCourseAudience>,
280 pub modules: Vec<CourseAuditingModuleUpdate>,
281}
282
283pub async fn insert(
284 conn: &mut PgConnection,
285 app_config: &ApplicationConfiguration,
286 pkey_policy: PKeyPolicy<Uuid>,
287 course_language_group_id: Uuid,
288 new_course: &NewCourse,
289) -> ModelResult<Uuid> {
290 let mut tx = conn.begin().await?;
291
292 let name = non_empty_trimmed(&new_course.name).unwrap_or(&new_course.name);
293 let description = non_empty_trimmed(&new_course.description);
294
295 let res = sqlx::query!(
296 "
297INSERT INTO courses(
298 id,
299 name,
300 description,
301 slug,
302 organization_id,
303 language_code,
304 course_language_group_id,
305 is_draft,
306 is_test_mode,
307 is_joinable_by_code_only,
308 join_code,
309 can_add_chatbot
310 )
311VALUES(
312 $1,
313 $2,
314 $3,
315 $4,
316 $5,
317 $6,
318 $7,
319 $8,
320 $9,
321 $10,
322 $11,
323 $12
324 )
325RETURNING id
326 ",
327 pkey_policy.into_uuid(),
328 name,
329 description,
330 new_course.slug,
331 new_course.organization_id,
332 new_course.language_code,
333 course_language_group_id,
334 new_course.is_draft,
335 new_course.is_test_mode,
336 new_course.is_joinable_by_code_only,
337 new_course.join_code,
338 new_course.can_add_chatbot,
339 )
340 .fetch_one(&mut *tx)
341 .await?;
342 if !app_config.disable_embedding_vector_creation_when_seeding {
343 update_course_embeddings(
344 &mut tx,
345 app_config,
346 res.id,
347 non_empty_trimmed(&new_course.name),
348 description,
349 )
350 .await?
351 }
352 tx.commit().await?;
353 Ok(res.id)
354}
355
356#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
357
358pub struct CourseStructure {
359 pub course: Course,
360 pub pages: Vec<Page>,
361 pub chapters: Vec<Chapter>,
362 pub modules: Vec<CourseModule>,
363}
364
365pub async fn all_courses(conn: &mut PgConnection) -> ModelResult<Vec<Course>> {
366 let courses = sqlx::query_as!(
367 Course,
368 r#"
369SELECT *
370FROM courses
371WHERE deleted_at IS NULL;
372"#
373 )
374 .fetch_all(conn)
375 .await?;
376 Ok(courses)
377}
378
379pub async fn all_courses_for_auditing(
380 conn: &mut PgConnection,
381) -> ModelResult<Vec<CourseAuditingData>> {
382 let courses_data = sqlx::query!(
383 r#"
384SELECT c.id,
385 c.name,
386 c.slug,
387 c.created_at,
388 c.updated_at,
389 c.organization_id,
390 c.description,
391 c.is_draft,
392 c.is_test_mode,
393 c.is_unlisted,
394 c.is_joinable_by_code_only,
395 c.closed_at,
396 c.closed_additional_message,
397 c.closed_course_successor_id,
398 o.name AS organization_name,
399 o.slug AS organization_slug
400FROM courses c
401 JOIN organizations o ON o.id = c.organization_id
402WHERE c.deleted_at IS NULL
403 AND o.deleted_at IS NULL
404GROUP BY c.id,
405 c.name,
406 o.name,
407 o.slug
408"#
409 )
410 .fetch_all(&mut *conn)
411 .await?;
412
413 let modules_data = crate::course_modules::get_all_modules(conn).await?;
414
415 let prerequisites_data =
416 crate::course_prerequisites::get_all_edit_course_prerequisites(conn).await?;
417
418 let audiences_data = crate::course_audiences::get_all_edit_course_audiences(conn).await?;
419
420 let mut modules_by_course_id: HashMap<Uuid, Vec<CourseModule>> = HashMap::new();
421 for module in modules_data {
422 modules_by_course_id
423 .entry(module.course_id)
424 .or_default()
425 .push(module);
426 }
427
428 for course_modules in modules_by_course_id.values_mut() {
429 course_modules.sort_by_key(|c| c.order_number);
430 }
431
432 let mut prerequisites_by_course_id: HashMap<Uuid, Vec<EditCoursePrerequisite>> = HashMap::new();
433 for prerequisite in prerequisites_data {
434 prerequisites_by_course_id
435 .entry(prerequisite.course_id)
436 .or_default()
437 .push(prerequisite);
438 }
439
440 let mut audiences_by_course_id: HashMap<Uuid, Vec<EditCourseAudience>> = HashMap::new();
441 for audience in audiences_data {
442 audiences_by_course_id
443 .entry(audience.course_id)
444 .or_default()
445 .push(audience);
446 }
447
448 let data: Vec<CourseAuditingData> = courses_data
449 .into_iter()
450 .map(|c| CourseAuditingData {
451 id: c.id,
452 name: c.name,
453 slug: c.slug,
454 created_at: c.created_at,
455 updated_at: c.updated_at,
456 organization_id: c.organization_id,
457 description: c.description,
458 is_draft: c.is_draft,
459 is_test_mode: c.is_test_mode,
460 is_unlisted: c.is_unlisted,
461 is_joinable_by_code_only: c.is_joinable_by_code_only,
462 closed_at: c.closed_at,
463 closed_additional_message: c.closed_additional_message,
464 closed_course_successor_id: c.closed_course_successor_id,
465 organization_name: c.organization_name,
466 organization_slug: c.organization_slug,
467 modules: modules_by_course_id.remove(&c.id).unwrap_or_default(),
468 prerequisites: prerequisites_by_course_id.remove(&c.id).unwrap_or_default(),
469 audiences: audiences_by_course_id.remove(&c.id).unwrap_or_default(),
470 })
471 .collect();
472
473 Ok(data)
474}
475
476pub async fn course_auditing_data_by_id(
477 conn: &mut PgConnection,
478 course_id: Uuid,
479) -> ModelResult<CourseAuditingData> {
480 let course_data = sqlx::query!(
481 r#"
482SELECT c.id,
483 c.name,
484 c.slug,
485 c.created_at,
486 c.updated_at,
487 c.organization_id,
488 c.description,
489 c.is_draft,
490 c.is_test_mode,
491 c.is_unlisted,
492 c.is_joinable_by_code_only,
493 c.closed_at,
494 c.closed_additional_message,
495 c.closed_course_successor_id,
496 o.name AS organization_name,
497 o.slug AS organization_slug
498FROM courses c
499 JOIN organizations o ON o.id = c.organization_id
500WHERE c.id = $1
501 AND c.deleted_at IS NULL
502 AND o.deleted_at IS NULL
503GROUP BY c.id,
504 c.name,
505 o.name,
506 o.slug
507"#,
508 course_id
509 )
510 .fetch_one(&mut *conn)
511 .await?;
512
513 let mut modules_data = crate::course_modules::get_by_course_id(conn, course_id).await?;
514
515 modules_data.sort_by_key(|c| c.order_number);
516
517 let prerequisites_data =
518 crate::course_prerequisites::get_edit_course_prerequisites_by_course_id(conn, course_id)
519 .await?;
520
521 let audiences_data =
522 crate::course_audiences::get_edit_course_audiences_by_course_id(conn, course_id).await?;
523
524 let data: CourseAuditingData = CourseAuditingData {
525 id: course_data.id,
526 name: course_data.name,
527 slug: course_data.slug,
528 created_at: course_data.created_at,
529 updated_at: course_data.updated_at,
530 organization_id: course_data.organization_id,
531 description: course_data.description,
532 is_draft: course_data.is_draft,
533 is_test_mode: course_data.is_test_mode,
534 is_unlisted: course_data.is_unlisted,
535 is_joinable_by_code_only: course_data.is_joinable_by_code_only,
536 closed_at: course_data.closed_at,
537 closed_additional_message: course_data.closed_additional_message,
538 closed_course_successor_id: course_data.closed_course_successor_id,
539 organization_name: course_data.organization_name,
540 organization_slug: course_data.organization_slug,
541 modules: modules_data,
542 prerequisites: prerequisites_data,
543 audiences: audiences_data,
544 };
545
546 Ok(data)
547}
548
549pub async fn update_course_auditing_data(
550 conn: &mut PgConnection,
551 app_config: &ApplicationConfiguration,
552 course_id: Uuid,
553 data_update: CourseAuditingDataUpdate,
554) -> ModelResult<()> {
555 for module in &data_update.modules {
556 crate::course_modules::validate_completion_registration_link(
557 module.completion_registration_link_override.as_deref(),
558 )?;
559 }
560 let (
561 module_ids,
562 uh_course_codes,
563 completion_registration_link_overrides,
564 ects_credits,
565 enable_registering_completion_to_uh_open_university,
566 ): (Vec<_>, Vec<_>, Vec<_>, Vec<_>, Vec<_>) =
567 multiunzip(data_update.modules.into_iter().map(|m| {
568 (
569 m.id,
570 m.uh_course_code,
571 m.completion_registration_link_override,
572 m.ects_credits,
573 m.enable_registering_completion_to_uh_open_university,
574 )
575 }));
576
577 let new_prerequisite_ids: Vec<Uuid> = data_update.prerequisites.iter().map(|p| p.id).collect();
578
579 let old_course = get_course(conn, course_id).await?;
580
581 let old_prerequisites: Vec<CoursePrerequisite> =
582 crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
583
584 let prerequisites_to_delete: Vec<Uuid> = old_prerequisites
585 .iter()
586 .filter(|p| !new_prerequisite_ids.contains(&p.id))
587 .map(|p| p.id.to_owned())
588 .collect();
589
590 let old_prerequisites_hash: HashMap<Uuid, &String> = old_prerequisites
591 .iter()
592 .map(|p| (p.id, &p.prerequisite))
593 .collect();
594
595 let changed_prerequisites: Vec<EditCoursePrerequisite> = data_update
596 .prerequisites
597 .into_iter()
598 .filter(|p| match old_prerequisites_hash.get(&p.id) {
599 Some(old_prerequisite) => **old_prerequisite != p.prerequisite,
600 None => true,
601 })
602 .collect();
603
604 let (updated_prerequisite_ids, updated_prerequisites): (Vec<Uuid>, Vec<String>) =
605 changed_prerequisites
606 .iter()
607 .map(|p| (p.id, p.prerequisite.to_owned()))
608 .unzip();
609
610 let new_audience_ids: Vec<Uuid> = data_update.audiences.iter().map(|a| a.id).collect();
611
612 let old_audiences: Vec<CourseAudience> =
613 crate::course_audiences::get_by_course_id(conn, course_id).await?;
614
615 let audiences_to_delete: Vec<Uuid> = old_audiences
616 .iter()
617 .filter(|a| !new_audience_ids.contains(&a.id))
618 .map(|a| a.id.to_owned())
619 .collect();
620
621 let old_audiences_hash: HashMap<Uuid, &String> =
622 old_audiences.iter().map(|a| (a.id, &a.audience)).collect();
623
624 let changed_audiences: Vec<EditCourseAudience> = data_update
625 .audiences
626 .into_iter()
627 .filter(|a| match old_audiences_hash.get(&a.id) {
628 Some(old_audience) => **old_audience != a.audience,
629 None => true,
630 })
631 .collect();
632
633 let (updated_audience_ids, updated_audiences): (Vec<Uuid>, Vec<String>) = changed_audiences
634 .iter()
635 .map(|a| (a.id, a.audience.to_owned()))
636 .unzip();
637
638 let prerequisite_embeddings = if updated_prerequisites.is_empty() {
639 None
640 } else {
641 Some(create_embeddings(app_config, updated_prerequisites.clone()).await?)
642 };
643
644 let audience_embeddings = if updated_audiences.is_empty() {
645 None
646 } else {
647 Some(create_embeddings(app_config, updated_audiences.clone()).await?)
648 };
649
650 let mut tx = conn.begin().await?;
651
652 sqlx::query_as!(
653 CourseAuditingDataUpdate,
654 r#"
655UPDATE courses
656SET description = $2,
657 is_draft = $3,
658 is_test_mode = $4,
659 is_unlisted = $5,
660 is_joinable_by_code_only = $6,
661 closed_at = $7,
662 closed_additional_message = $8,
663 closed_course_successor_id = $9
664WHERE id = $1
665 AND deleted_at IS NULL
666"#,
667 course_id,
668 data_update.description,
669 data_update.is_draft,
670 data_update.is_test_mode,
671 data_update.is_unlisted,
672 data_update.is_joinable_by_code_only,
673 data_update.closed_at,
674 data_update.closed_additional_message,
675 data_update.closed_course_successor_id
676 )
677 .execute(&mut *tx)
678 .await?;
679
680 if old_course.description != data_update.description
681 && data_update.description.is_some()
682 && data_update.description != Some("".to_string())
683 {
684 update_course_embeddings(
685 &mut tx,
686 app_config,
687 course_id,
688 Some(old_course.name.as_str()),
689 data_update.description.as_deref(),
690 )
691 .await?;
692 };
693
694 sqlx::query!(
695 r#"
696UPDATE course_modules cm
697SET uh_course_code = v.uh_course_code,
698 completion_registration_link_override = v.completion_registration_link_override,
699 ects_credits = v.ects_credits,
700 enable_registering_completion_to_uh_open_university = v.enable_registering_completion_to_uh_open_university
701FROM (
702 SELECT *
703 FROM UNNEST(
704 $1::uuid [],
705 $2::TEXT [],
706 $3::TEXT [],
707 $4::FLOAT8 [],
708 $5::BOOLEAN []
709 ) AS v(
710 id,
711 uh_course_code,
712 completion_registration_link_override,
713 ects_credits,
714 enable_registering_completion_to_uh_open_university
715 )
716 ) AS v
717WHERE cm.id = v.id
718 AND cm.course_id = $6
719 AND cm.deleted_at IS NULL
720"#,
721 &module_ids,
722 &uh_course_codes as &[Option<String>],
723 &completion_registration_link_overrides as &[Option<String>],
724 &ects_credits as &[Option<f32>],
725 &enable_registering_completion_to_uh_open_university,
726 course_id
727 )
728 .execute(&mut *tx)
729 .await?;
730
731 if let Some(embeddings) = prerequisite_embeddings {
732 upsert_course_prerequisites(
733 &mut tx,
734 course_id,
735 updated_prerequisite_ids,
736 updated_prerequisites,
737 embeddings,
738 )
739 .await?
740 } else {
741 vec![]
742 };
743
744 if let Some(embeddings) = audience_embeddings {
745 upsert_course_audiences(
746 &mut tx,
747 course_id,
748 updated_audience_ids,
749 updated_audiences,
750 embeddings,
751 )
752 .await?
753 } else {
754 vec![]
755 };
756
757 crate::course_prerequisites::delete_batch(&mut tx, prerequisites_to_delete).await?;
758
759 crate::course_audiences::delete_batch(&mut tx, audiences_to_delete).await?;
760
761 tx.commit().await?;
762 Ok(())
763}
764
765pub async fn all_courses_user_enrolled_to(
766 conn: &mut PgConnection,
767 user_id: Uuid,
768) -> ModelResult<Vec<Course>> {
769 let courses = sqlx::query_as!(
770 Course,
771 r#"
772SELECT *
773FROM courses
774WHERE courses.deleted_at IS NULL
775 AND id IN (
776 SELECT current_course_id
777 FROM user_course_settings
778 WHERE deleted_at IS NULL
779 AND user_id = $1
780 )
781"#,
782 user_id
783 )
784 .fetch_all(conn)
785 .await?;
786 Ok(courses)
787}
788
789pub async fn all_courses_with_roles_for_user(
790 conn: &mut PgConnection,
791 user_id: Uuid,
792) -> ModelResult<Vec<Course>> {
793 let courses = sqlx::query_as!(
794 Course,
795 r#"
796SELECT *
797FROM courses
798WHERE courses.deleted_at IS NULL
799 AND (
800 id IN (
801 SELECT course_id
802 FROM roles
803 WHERE deleted_at IS NULL
804 AND user_id = $1
805 AND course_id IS NOT NULL
806 )
807 OR (
808 id IN (
809 SELECT ci.course_id
810 FROM course_instances ci
811 JOIN ROLES r ON r.course_instance_id = ci.id
812 WHERE r.user_id = $1
813 AND r.deleted_at IS NULL
814 AND ci.deleted_at IS NULL
815 )
816 )
817 ) "#,
818 user_id
819 )
820 .fetch_all(conn)
821 .await?;
822 Ok(courses)
823}
824
825pub async fn get_all_language_versions_of_course(
826 conn: &mut PgConnection,
827 course: &Course,
828) -> ModelResult<Vec<Course>> {
829 let courses = sqlx::query_as!(
830 Course,
831 r#"
832SELECT *
833FROM courses
834WHERE course_language_group_id = $1
835AND deleted_at IS NULL
836 "#,
837 course.course_language_group_id,
838 )
839 .fetch_all(conn)
840 .await?;
841 Ok(courses)
842}
843
844pub async fn get_active_courses_for_organization(
845 conn: &mut PgConnection,
846 organization_id: Uuid,
847 pagination: Pagination,
848) -> ModelResult<Vec<Course>> {
849 let course_instances = sqlx::query_as!(
850 Course,
851 r#"
852SELECT
853 DISTINCT(c.id),
854 c.name,
855 c.created_at,
856 c.updated_at,
857 c.organization_id,
858 c.deleted_at,
859 c.slug,
860 c.content_search_language::text,
861 c.language_code,
862 c.copied_from,
863 c.course_language_group_id,
864 c.description,
865 c.is_draft,
866 c.is_test_mode,
867 c.base_module_completion_requires_n_submodule_completions,
868 c.can_add_chatbot,
869 c.is_unlisted,
870 c.is_joinable_by_code_only,
871 c.join_code,
872 c.ask_marketing_consent,
873 c.flagged_answers_threshold,
874 c.flagged_answers_skip_manual_review_and_allow_retry,
875 c.closed_at,
876 c.closed_additional_message,
877 c.closed_course_successor_id,
878 c.chapter_locking_enabled,
879 c.cheater_detection_enabled,
880 c.ai_policy,
881 c.course_material_ai_instructions
882FROM courses as c
883 LEFT JOIN course_instances as ci on c.id = ci.course_id
884WHERE
885 c.organization_id = $1 AND
886 ci.starts_at < NOW() AND ci.ends_at > NOW() AND
887 c.deleted_at IS NULL AND ci.deleted_at IS NULL
888 LIMIT $2 OFFSET $3;
889 "#,
890 organization_id,
891 pagination.limit(),
892 pagination.offset()
893 )
894 .fetch_all(conn)
895 .await?;
896 Ok(course_instances)
897}
898
899pub async fn get_active_courses_for_organization_count(
900 conn: &mut PgConnection,
901 organization_id: Uuid,
902) -> ModelResult<CourseCount> {
903 let result = sqlx::query!(
904 r#"
905SELECT
906 COUNT(DISTINCT c.id) as count
907FROM courses as c
908 LEFT JOIN course_instances as ci on c.id = ci.course_id
909WHERE
910 c.organization_id = $1 AND
911 ci.starts_at < NOW() AND ci.ends_at > NOW() AND
912 c.deleted_at IS NULL AND ci.deleted_at IS NULL;
913 "#,
914 organization_id
915 )
916 .fetch_one(conn)
917 .await?;
918 Ok(CourseCount {
919 count: result.count.unwrap_or_default().try_into()?,
920 })
921}
922
923pub async fn get_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
924 let course = sqlx::query_as!(
925 Course,
926 r#"
927SELECT *
928FROM courses
929WHERE id = $1
930 AND deleted_at IS NULL;
931 "#,
932 course_id
933 )
934 .fetch_one(conn)
935 .await?;
936 Ok(course)
937}
938
939pub async fn get_by_id_and_join_code(
940 conn: &mut PgConnection,
941 course_id: Uuid,
942 join_code: &str,
943) -> ModelResult<Course> {
944 let course = sqlx::query_as!(
945 Course,
946 r#"
947SELECT *
948FROM courses
949WHERE id = $1
950 AND join_code = $2
951 AND deleted_at IS NULL;
952 "#,
953 course_id,
954 join_code,
955 )
956 .fetch_one(conn)
957 .await?;
958 Ok(course)
959}
960
961pub async fn get_course_breadcrumb_info(
962 conn: &mut PgConnection,
963 course_id: Uuid,
964) -> ModelResult<CourseBreadcrumbInfo> {
965 let res = sqlx::query_as!(
966 CourseBreadcrumbInfo,
967 r#"
968SELECT courses.id as course_id,
969 courses.name as course_name,
970 courses.slug as course_slug,
971 organizations.slug as organization_slug,
972 organizations.name as organization_name
973FROM courses
974 JOIN organizations ON (courses.organization_id = organizations.id)
975WHERE courses.id = $1
976 AND courses.deleted_at IS NULL;
977 "#,
978 course_id
979 )
980 .fetch_one(conn)
981 .await?;
982 Ok(res)
983}
984
985pub async fn get_nondeleted_course_id_by_slug(
986 conn: &mut PgConnection,
987 slug: &str,
988) -> ModelResult<CourseContextData> {
989 let data = sqlx::query_as!(
990 CourseContextData,
991 "SELECT id, is_test_mode FROM courses WHERE slug = $1 AND deleted_at IS NULL",
992 slug
993 )
994 .fetch_one(conn)
995 .await?;
996 Ok(data)
997}
998
999pub async fn get_active_course_id_by_slug(
1001 conn: &mut PgConnection,
1002 slug: &str,
1003) -> ModelResult<Option<Uuid>> {
1004 let id = sqlx::query_scalar!(
1005 "SELECT id FROM courses WHERE slug = $1 AND deleted_at IS NULL",
1006 slug
1007 )
1008 .fetch_optional(conn)
1009 .await?;
1010 Ok(id)
1011}
1012
1013pub async fn get_organization_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
1014 let organization_id = sqlx::query!("SELECT organization_id FROM courses WHERE id = $1", id)
1015 .fetch_one(conn)
1016 .await?
1017 .organization_id;
1018 Ok(organization_id)
1019}
1020
1021pub async fn get_course_structure(
1023 conn: &mut PgConnection,
1024 course_id: Uuid,
1025 file_store: &dyn FileStore,
1026 app_conf: &ApplicationConfiguration,
1027) -> ModelResult<CourseStructure> {
1028 let course = get_course(conn, course_id).await?;
1029 let pages = get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
1030 let chapters = get_course_chapters(conn, course_id)
1031 .await?
1032 .iter()
1033 .map(|chapter| Chapter::from_database_chapter(chapter, file_store, app_conf))
1034 .collect();
1035 let modules = crate::course_modules::get_by_course_id(conn, course_id).await?;
1036 Ok(CourseStructure {
1037 course,
1038 pages,
1039 chapters,
1040 modules,
1041 })
1042}
1043
1044pub async fn organization_courses_visible_to_user_paginated(
1045 conn: &mut PgConnection,
1046 organization_id: Uuid,
1047 user: Option<Uuid>,
1048 pagination: Pagination,
1049) -> ModelResult<Vec<Course>> {
1050 let courses = sqlx::query_as!(
1051 Course,
1052 r#"
1053SELECT courses.id,
1054 courses.name,
1055 courses.created_at,
1056 courses.updated_at,
1057 courses.organization_id,
1058 courses.deleted_at,
1059 courses.slug,
1060 courses.content_search_language::text,
1061 courses.language_code,
1062 courses.copied_from,
1063 courses.course_language_group_id,
1064 courses.description,
1065 courses.is_draft,
1066 courses.is_test_mode,
1067 base_module_completion_requires_n_submodule_completions,
1068 can_add_chatbot,
1069 courses.is_unlisted,
1070 courses.is_joinable_by_code_only,
1071 courses.join_code,
1072 courses.ask_marketing_consent,
1073 courses.flagged_answers_threshold,
1074 courses.flagged_answers_skip_manual_review_and_allow_retry,
1075 courses.closed_at,
1076 courses.closed_additional_message,
1077 courses.closed_course_successor_id,
1078 courses.chapter_locking_enabled,
1079 courses.cheater_detection_enabled,
1080 courses.ai_policy,
1081 courses.course_material_ai_instructions
1082FROM courses
1083WHERE courses.organization_id = $1
1084 AND (
1085 (
1086 courses.is_draft IS FALSE
1087 AND courses.is_unlisted IS FALSE
1088 )
1089 OR EXISTS (
1090 SELECT id
1091 FROM roles
1092 WHERE user_id = $2
1093 AND (
1094 course_id = courses.id
1095 OR roles.organization_id = courses.organization_id
1096 OR roles.is_global IS TRUE
1097 )
1098 )
1099 )
1100 AND courses.deleted_at IS NULL
1101ORDER BY courses.name
1102LIMIT $3 OFFSET $4;
1103"#,
1104 organization_id,
1105 user,
1106 pagination.limit(),
1107 pagination.offset()
1108 )
1109 .fetch_all(conn)
1110 .await?;
1111 Ok(courses)
1112}
1113
1114pub async fn organization_course_count(
1115 conn: &mut PgConnection,
1116 organization_id: Uuid,
1117) -> ModelResult<CourseCount> {
1118 let course_count = sqlx::query!(
1119 r#"
1120SELECT
1121 COUNT(DISTINCT id) as count
1122FROM courses
1123WHERE organization_id = $1
1124 AND deleted_at IS NULL;
1125 "#,
1126 organization_id,
1127 )
1128 .fetch_one(conn)
1129 .await?;
1130 Ok(CourseCount {
1131 count: course_count.count.unwrap_or_default().try_into()?,
1132 })
1133}
1134#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default, ToSchema)]
1136
1137pub struct CourseUpdate {
1138 pub name: String,
1139 pub description: Option<String>,
1140 pub is_draft: bool,
1141 pub is_test_mode: bool,
1142 pub can_add_chatbot: bool,
1143 pub is_unlisted: bool,
1144 pub is_joinable_by_code_only: bool,
1145 pub ask_marketing_consent: bool,
1146 pub flagged_answers_threshold: i32,
1147 pub flagged_answers_skip_manual_review_and_allow_retry: bool,
1148 pub closed_at: Option<DateTime<Utc>>,
1149 pub closed_additional_message: Option<String>,
1150 pub closed_course_successor_id: Option<Uuid>,
1151 pub chapter_locking_enabled: bool,
1152 pub ai_policy: CourseAiPolicy,
1153 pub course_material_ai_instructions: Option<bool>,
1154}
1155
1156pub async fn update_course(
1157 conn: &mut PgConnection,
1158 app_config: &ApplicationConfiguration,
1159 course_id: Uuid,
1160 course_update: CourseUpdate,
1161) -> ModelResult<Course> {
1162 let old_course = get_course(conn, course_id).await?;
1163 let mut tx = conn.begin().await?;
1164 let res = sqlx::query_as!(
1165 Course,
1166 r#"
1167UPDATE courses
1168SET name = $1,
1169 description = $2,
1170 is_draft = $3,
1171 is_test_mode = $4,
1172 can_add_chatbot = $5,
1173 is_unlisted = $6,
1174 is_joinable_by_code_only = $7,
1175 ask_marketing_consent = $8,
1176 flagged_answers_threshold = $9,
1177 flagged_answers_skip_manual_review_and_allow_retry = $10,
1178 closed_at = $11,
1179 closed_additional_message = $12,
1180 closed_course_successor_id = $13,
1181 chapter_locking_enabled = $14,
1182 ai_policy = $15,
1183 course_material_ai_instructions = $16
1184WHERE id = $17
1185 AND deleted_at IS NULL
1186RETURNING *
1187 "#,
1188 course_update.name,
1189 course_update.description,
1190 course_update.is_draft,
1191 course_update.is_test_mode,
1192 course_update.can_add_chatbot,
1193 course_update.is_unlisted,
1194 course_update.is_joinable_by_code_only,
1195 course_update.ask_marketing_consent,
1196 course_update.flagged_answers_threshold,
1197 course_update.flagged_answers_skip_manual_review_and_allow_retry,
1198 course_update.closed_at,
1199 course_update.closed_additional_message,
1200 course_update.closed_course_successor_id,
1201 course_update.chapter_locking_enabled,
1202 course_update.ai_policy as CourseAiPolicy,
1203 course_update.course_material_ai_instructions,
1204 course_id
1205 )
1206 .fetch_one(&mut *tx)
1207 .await?;
1208 let title = if old_course.name != course_update.name {
1209 Some(course_update.name.as_str())
1210 } else {
1211 None
1212 };
1213
1214 let description = if old_course.description != course_update.description {
1215 course_update.description.as_deref()
1216 } else {
1217 None
1218 };
1219
1220 update_course_embeddings(&mut tx, app_config, course_id, title, description).await?;
1221 tx.commit().await?;
1222 Ok(res)
1223}
1224
1225pub async fn set_cheater_detection_enabled(
1229 conn: &mut PgConnection,
1230 course_id: Uuid,
1231 enabled: bool,
1232) -> ModelResult<()> {
1233 sqlx::query!(
1234 "
1235UPDATE courses
1236SET cheater_detection_enabled = $1
1237WHERE id = $2
1238 AND deleted_at IS NULL
1239 ",
1240 enabled,
1241 course_id,
1242 )
1243 .execute(conn)
1244 .await?;
1245 Ok(())
1246}
1247
1248pub async fn update_course_base_module_completion_count_requirement(
1249 conn: &mut PgConnection,
1250 id: Uuid,
1251 base_module_completion_requires_n_submodule_completions: i32,
1252) -> ModelResult<bool> {
1253 let res = sqlx::query!(
1254 "
1255UPDATE courses
1256SET base_module_completion_requires_n_submodule_completions = $1
1257WHERE id = $2
1258 AND deleted_at IS NULL
1259 ",
1260 base_module_completion_requires_n_submodule_completions,
1261 id,
1262 )
1263 .execute(conn)
1264 .await?;
1265 Ok(res.rows_affected() > 0)
1266}
1267
1268pub async fn delete_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
1269 let deleted = sqlx::query_as!(
1270 Course,
1271 r#"
1272UPDATE courses
1273SET deleted_at = now()
1274WHERE id = $1
1275AND deleted_at IS NULL
1276RETURNING *
1277 "#,
1278 course_id
1279 )
1280 .fetch_one(conn)
1281 .await?;
1282 Ok(deleted)
1283}
1284
1285pub async fn get_course_by_slug(conn: &mut PgConnection, course_slug: &str) -> ModelResult<Course> {
1286 let course = sqlx::query_as!(
1287 Course,
1288 r#"
1289SELECT *
1290FROM courses
1291WHERE slug = $1
1292 AND deleted_at IS NULL
1293"#,
1294 course_slug,
1295 )
1296 .fetch_one(conn)
1297 .await?;
1298 Ok(course)
1299}
1300
1301pub async fn get_cfgname_by_tag(
1302 conn: &mut PgConnection,
1303 ietf_language_tag: String,
1304) -> ModelResult<String> {
1305 let tag = ietf_language_tag
1306 .split('-')
1307 .next()
1308 .unwrap_or_else(|| &ietf_language_tag[..]);
1309
1310 let lang_name = LANGUAGE_TAG_TO_NAME.get(&tag);
1311
1312 let name = sqlx::query!(
1313 "SELECT cfgname::text FROM pg_ts_config WHERE cfgname = $1",
1314 lang_name
1315 )
1316 .fetch_optional(conn)
1317 .await?;
1318
1319 let res = name
1320 .and_then(|n| n.cfgname)
1321 .unwrap_or_else(|| "simple".to_string());
1322
1323 Ok(res)
1324}
1325
1326pub async fn is_draft(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
1327 let res = sqlx::query!(
1328 "
1329SELECT is_draft
1330FROM courses
1331WHERE id = $1
1332",
1333 id
1334 )
1335 .fetch_one(conn)
1336 .await?;
1337 Ok(res.is_draft)
1338}
1339
1340pub async fn is_joinable_by_code_only(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
1341 let res = sqlx::query!(
1342 "
1343SELECT is_joinable_by_code_only
1344FROM courses
1345WHERE id = $1
1346",
1347 id
1348 )
1349 .fetch_one(conn)
1350 .await?;
1351 Ok(res.is_joinable_by_code_only)
1352}
1353
1354pub async fn get_by_ids(conn: &mut PgConnection, course_ids: &[Uuid]) -> ModelResult<Vec<Course>> {
1355 let courses = sqlx::query_as!(
1356 Course,
1357 r#"
1358SELECT *
1359FROM courses
1360WHERE id IN (SELECT * FROM UNNEST($1::uuid[]))
1361 AND deleted_at IS NULL
1362 "#,
1363 course_ids
1364 )
1365 .fetch_all(conn)
1366 .await?;
1367 Ok(courses)
1368}
1369
1370pub async fn search_courses_by_slug_or_name(
1373 conn: &mut PgConnection,
1374 query: &str,
1375 limit: i64,
1376) -> ModelResult<Vec<Course>> {
1377 let courses = sqlx::query_as!(
1378 Course,
1379 r#"
1380SELECT *
1381FROM courses
1382WHERE deleted_at IS NULL
1383 AND (LOWER(slug) = LOWER($1) OR name ILIKE '%' || $1 || '%' OR similarity(name, $1) > 0.1)
1384ORDER BY
1385 LOWER(slug) = LOWER($1) DESC,
1386 name ILIKE '%' || $1 || '%' DESC,
1387 similarity(name, $1) DESC
1388LIMIT $2
1389 "#,
1390 query,
1391 limit,
1392 )
1393 .fetch_all(conn)
1394 .await?;
1395 Ok(courses)
1396}
1397
1398pub async fn get_by_organization_id(
1399 conn: &mut PgConnection,
1400 organization_id: Uuid,
1401) -> ModelResult<Vec<Course>> {
1402 let courses = sqlx::query_as!(
1403 Course,
1404 r#"
1405SELECT *
1406FROM courses
1407WHERE organization_id = $1
1408 AND deleted_at IS NULL
1409ORDER BY name
1410 "#,
1411 organization_id
1412 )
1413 .fetch_all(conn)
1414 .await?;
1415 Ok(courses)
1416}
1417
1418pub async fn set_join_code_for_course(
1419 conn: &mut PgConnection,
1420 course_id: Uuid,
1421 join_code: String,
1422) -> ModelResult<()> {
1423 sqlx::query!(
1424 "
1425UPDATE courses
1426SET join_code = $2
1427WHERE id = $1
1428",
1429 course_id,
1430 join_code
1431 )
1432 .execute(conn)
1433 .await?;
1434 Ok(())
1435}
1436
1437pub async fn get_course_with_join_code(
1438 conn: &mut PgConnection,
1439 join_code: String,
1440) -> ModelResult<Course> {
1441 let course = sqlx::query_as!(
1442 Course,
1443 r#"
1444SELECT *
1445FROM courses
1446WHERE join_code = $1
1447 AND deleted_at IS NULL;
1448 "#,
1449 join_code,
1450 )
1451 .fetch_one(conn)
1452 .await?;
1453 Ok(course)
1454}
1455
1456pub async fn set_metadata(
1457 conn: &mut PgConnection,
1458 app_config: &ApplicationConfiguration,
1459 course_id: Uuid,
1460 course_metadata: CourseMetadataUpdate,
1461) -> ModelResult<CourseMetadata> {
1462 let new_prerequisite_ids: Vec<Uuid> = course_metadata
1463 .course_prerequisites
1464 .iter()
1465 .map(|p| p.id)
1466 .collect();
1467
1468 let old_prerequisites: Vec<CoursePrerequisite> =
1469 crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1470
1471 let prerequisites_to_delete: Vec<Uuid> = old_prerequisites
1472 .iter()
1473 .filter(|p| !new_prerequisite_ids.contains(&p.id))
1474 .map(|p| p.id.to_owned())
1475 .collect();
1476
1477 let old_prerequisites_hash: HashMap<Uuid, &String> = old_prerequisites
1478 .iter()
1479 .map(|p| (p.id, &p.prerequisite))
1480 .collect();
1481
1482 let changed_prerequisites: Vec<EditCoursePrerequisite> = course_metadata
1483 .course_prerequisites
1484 .into_iter()
1485 .filter(|p| match old_prerequisites_hash.get(&p.id) {
1486 Some(old_prerequisite) => **old_prerequisite != p.prerequisite,
1487 None => true,
1488 })
1489 .collect();
1490
1491 let (updated_prerequisite_ids, updated_prerequisites): (Vec<Uuid>, Vec<String>) =
1492 changed_prerequisites
1493 .iter()
1494 .map(|p| (p.id, p.prerequisite.to_owned()))
1495 .unzip();
1496
1497 let audience_ids: Vec<Uuid> = course_metadata
1498 .course_audiences
1499 .iter()
1500 .map(|a| a.id)
1501 .collect();
1502
1503 let old_audiences: Vec<CourseAudience> =
1504 crate::course_audiences::get_by_course_id(conn, course_id).await?;
1505
1506 let audiences_to_delete: Vec<Uuid> = old_audiences
1507 .iter()
1508 .filter(|a| !audience_ids.contains(&a.id))
1509 .map(|a| a.id.to_owned())
1510 .collect();
1511
1512 let old_audiences_hash: HashMap<Uuid, &String> =
1513 old_audiences.iter().map(|a| (a.id, &a.audience)).collect();
1514
1515 let changed_audiences: Vec<EditCourseAudience> = course_metadata
1516 .course_audiences
1517 .into_iter()
1518 .filter(|a| match old_audiences_hash.get(&a.id) {
1519 Some(old_audience) => **old_audience != a.audience,
1520 None => true,
1521 })
1522 .collect();
1523
1524 let (updated_audience_ids, updated_audiences): (Vec<Uuid>, Vec<String>) = changed_audiences
1525 .iter()
1526 .map(|a| (a.id, a.audience.to_owned()))
1527 .unzip();
1528
1529 let prerequisite_embeddings = if updated_prerequisites.is_empty() {
1530 None
1531 } else {
1532 Some(create_embeddings(app_config, updated_prerequisites.clone()).await?)
1533 };
1534
1535 let audience_embeddings = if updated_audiences.is_empty() {
1536 None
1537 } else {
1538 Some(create_embeddings(app_config, updated_audiences.clone()).await?)
1539 };
1540
1541 let mut tx = conn.begin().await?;
1542
1543 if let Some(embeddings) = prerequisite_embeddings {
1544 upsert_course_prerequisites(
1545 &mut tx,
1546 course_id,
1547 updated_prerequisite_ids,
1548 updated_prerequisites,
1549 embeddings,
1550 )
1551 .await?
1552 } else {
1553 vec![]
1554 };
1555
1556 if let Some(embeddings) = audience_embeddings {
1557 upsert_course_audiences(
1558 &mut tx,
1559 course_id,
1560 updated_audience_ids,
1561 updated_audiences,
1562 embeddings,
1563 )
1564 .await?
1565 } else {
1566 vec![]
1567 };
1568
1569 crate::course_prerequisites::delete_batch(&mut tx, prerequisites_to_delete).await?;
1570 crate::course_audiences::delete_batch(&mut tx, audiences_to_delete).await?;
1571
1572 let course = get_course(&mut tx, course_id).await?;
1573
1574 let update_payload = CourseUpdate {
1575 name: course.name,
1576 description: course_metadata.course_description,
1577 is_draft: course.is_draft,
1578 is_test_mode: course.is_test_mode,
1579 can_add_chatbot: course.can_add_chatbot,
1580 is_unlisted: course.is_unlisted,
1581 is_joinable_by_code_only: course.is_joinable_by_code_only,
1582 ask_marketing_consent: course.ask_marketing_consent,
1583 flagged_answers_threshold: course.flagged_answers_threshold.unwrap_or(0),
1584 flagged_answers_skip_manual_review_and_allow_retry: course
1585 .flagged_answers_skip_manual_review_and_allow_retry,
1586 closed_at: course.closed_at,
1587 closed_additional_message: course.closed_additional_message,
1588 closed_course_successor_id: course.closed_course_successor_id,
1589 chapter_locking_enabled: course.chapter_locking_enabled,
1590 ai_policy: course.ai_policy,
1591 course_material_ai_instructions: course.course_material_ai_instructions,
1592 };
1593 let updated_course = update_course(&mut tx, app_config, course_id, update_payload).await?;
1594
1595 tx.commit().await?;
1596
1597 let prerequisites = crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1598 let audiences = crate::course_audiences::get_by_course_id(conn, course_id).await?;
1599
1600 let res = CourseMetadata {
1601 course_description: updated_course.description,
1602 course_audiences: audiences,
1603 course_prerequisites: prerequisites,
1604 course_updated_at: updated_course.updated_at,
1605 };
1606 Ok(res)
1607}
1608
1609pub async fn get_metadata(
1610 conn: &mut PgConnection,
1611 course_id: Uuid,
1612) -> ModelResult<CompleteCourseMetadata> {
1613 let prerequisites: Vec<CoursePrerequisite> =
1614 crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1615 let audiences: Vec<CourseAudience> =
1616 crate::course_audiences::get_by_course_id(conn, course_id).await?;
1617 let course_data = get_course(conn, course_id).await?;
1618 let instances =
1619 crate::course_instances::get_course_instances_for_course(conn, course_id).await?;
1620 let module = crate::course_modules::get_default_by_course_id(conn, course_id).await?;
1621
1622 let organization =
1623 crate::organizations::get_organization(conn, course_data.organization_id).await?;
1624
1625 let metadata = CompleteCourseMetadata {
1626 course: course_data,
1627 course_instances: instances,
1628 default_module: module,
1629 course_prerequisites: prerequisites,
1630 course_audiences: audiences,
1631 course_organization: organization,
1632 };
1633 Ok(metadata)
1634}
1635
1636pub async fn update_course_embeddings(
1637 conn: &mut PgConnection,
1638 app_config: &ApplicationConfiguration,
1639 course_id: Uuid,
1640 title: Option<&str>,
1641 description: Option<&str>,
1642) -> ModelResult<()> {
1643 let title_embedding = if let Some(title) = title {
1644 Some(
1645 create_embeddings(app_config, vec![title.to_owned()])
1646 .await?
1647 .into_iter()
1648 .next()
1649 .ok_or_else(|| {
1650 model_err!(Generic, "The embedding API returned no title embedding.")
1651 })
1652 .map(Vector::from)?,
1653 )
1654 } else {
1655 None
1656 };
1657
1658 let description_embedding = if let Some(description) = description {
1659 Some(
1660 create_embeddings(app_config, vec![description.to_owned()])
1661 .await?
1662 .into_iter()
1663 .next()
1664 .ok_or_else(|| {
1665 model_err!(
1666 Generic,
1667 "The embedding API returned no description embedding."
1668 )
1669 })
1670 .map(Vector::from)?,
1671 )
1672 } else {
1673 None
1674 };
1675
1676 sqlx::query!(
1677 r#"
1678INSERT INTO course_embeddings (
1679 course_id,
1680 title_embedding,
1681 description_embedding
1682)
1683VALUES ($1, $2, $3)
1684ON CONFLICT (course_id) WHERE deleted_at IS NULL
1685DO UPDATE SET
1686 title_embedding = COALESCE(
1687 $2,
1688 course_embeddings.title_embedding
1689 ),
1690 description_embedding = COALESCE(
1691 $3,
1692 course_embeddings.description_embedding
1693 )
1694"#,
1695 course_id,
1696 title_embedding,
1697 description_embedding,
1698 )
1699 .execute(conn)
1700 .await?;
1701
1702 Ok(())
1703}
1704
1705pub async fn get_by_description_vectors(
1706 conn: &mut PgConnection,
1707 query_vecs: Vec<Vec<f32>>,
1708 description_keywords: Vec<String>,
1709) -> ModelResult<Vec<Uuid>> {
1710 let vectors: Vec<Vector> = query_vecs.into_iter().map(Vector::from).collect();
1711 let res = sqlx::query_scalar!(
1712 r#"
1713SELECT id
1714FROM (
1715 SELECT
1716 c.id,
1717 LEAST(MIN(ce.title_embedding <#> v.embedding),
1718 MIN(ce.description_embedding <#> v.embedding)) AS distance
1719 FROM courses c, course_embeddings ce
1720 CROSS JOIN unnest($1::vector[]) AS v(embedding)
1721 WHERE c.deleted_at IS NULL AND ce.deleted_at IS NULL AND c.id = ce.course_id
1722 GROUP BY c.id
1723 ORDER BY distance ASC
1724 LIMIT 5
1725) t
1726UNION ALL
1727SELECT DISTINCT c.id
1728FROM courses c
1729CROSS JOIN unnest($2::text[]) AS k(keyword)
1730WHERE deleted_at IS NULL
1731 AND to_tsvector(
1732 c.content_search_language::regconfig,
1733 c.name || ' ' || coalesce(c.description, '')
1734 ) @@ websearch_to_tsquery(
1735 c.content_search_language::regconfig,
1736 k.keyword
1737 )
1738 "#,
1739 &vectors as _,
1740 &description_keywords
1741 )
1742 .fetch_all(conn)
1743 .await?;
1744 Ok(res.into_iter().flatten().collect())
1745}
1746
1747#[cfg(test)]
1748mod test {
1749 use super::*;
1750 use crate::{course_language_groups, courses, test_helper::*};
1751
1752 mod language_code_validation {
1753 use super::*;
1754
1755 #[tokio::test]
1756 async fn allows_valid_language_code() {
1757 let app_config =
1758 init_app_conf().expect("Application Configuration initialization failed");
1759 insert_data!(:tx, user: _user, :org);
1760 let course_language_group_id = course_language_groups::insert(
1761 tx.as_mut(),
1762 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1763 "test-clg-allows-valid",
1764 )
1765 .await
1766 .unwrap();
1767 let new_course = create_new_course(org, "en-US");
1768 let res = courses::insert(
1769 tx.as_mut(),
1770 &app_config,
1771 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1772 course_language_group_id,
1773 &new_course,
1774 )
1775 .await;
1776 assert!(res.is_ok());
1777 }
1778
1779 #[tokio::test]
1780 async fn disallows_empty_language_code() {
1781 let app_config =
1782 init_app_conf().expect("Application Configuration initialization failed");
1783 insert_data!(:tx, user: _user, :org);
1784 let course_language_group_id = course_language_groups::insert(
1785 tx.as_mut(),
1786 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1787 "test-clg-disallows-empty",
1788 )
1789 .await
1790 .unwrap();
1791 let new_course = create_new_course(org, "");
1792 let res = courses::insert(
1793 tx.as_mut(),
1794 &app_config,
1795 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1796 course_language_group_id,
1797 &new_course,
1798 )
1799 .await;
1800 assert!(res.is_err());
1801 }
1802
1803 #[tokio::test]
1804 async fn disallows_wrong_case_language_code() {
1805 let app_config =
1806 init_app_conf().expect("Application Configuration initialization failed");
1807 insert_data!(:tx, user: _user, :org);
1808 let course_language_group_id = course_language_groups::insert(
1809 tx.as_mut(),
1810 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1811 "test-clg-disallows-wrong-case",
1812 )
1813 .await
1814 .unwrap();
1815 let new_course = create_new_course(org, "en-us");
1816 let res = courses::insert(
1817 tx.as_mut(),
1818 &app_config,
1819 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1820 course_language_group_id,
1821 &new_course,
1822 )
1823 .await;
1824 assert!(res.is_err());
1825 }
1826
1827 #[tokio::test]
1828 async fn disallows_underscore_in_language_code() {
1829 let app_config =
1830 init_app_conf().expect("Application Configuration initialization failed");
1831 insert_data!(:tx, user: _user, :org);
1832 let course_language_group_id = course_language_groups::insert(
1833 tx.as_mut(),
1834 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1835 "test-clg-disallows-underscore",
1836 )
1837 .await
1838 .unwrap();
1839 let new_course = create_new_course(org, "en_US");
1840 let res = courses::insert(
1841 tx.as_mut(),
1842 &app_config,
1843 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1844 course_language_group_id,
1845 &new_course,
1846 )
1847 .await;
1848 assert!(res.is_err());
1849 }
1850
1851 fn create_new_course(organization_id: Uuid, language_code: &str) -> NewCourse {
1852 NewCourse {
1853 name: "".to_string(),
1854 slug: "".to_string(),
1855 organization_id,
1856 language_code: language_code.to_string(),
1857 teacher_in_charge_name: "teacher".to_string(),
1858 teacher_in_charge_email: "teacher@example.com".to_string(),
1859 description: "description".to_string(),
1860 is_draft: false,
1861 is_test_mode: false,
1862 is_unlisted: false,
1863 copy_user_permissions: false,
1864 is_joinable_by_code_only: false,
1865 join_code: None,
1866 ask_marketing_consent: false,
1867 flagged_answers_threshold: Some(3),
1868 can_add_chatbot: false,
1869 }
1870 }
1871 }
1872
1873 mod ai_policy {
1874 use super::*;
1875
1876 #[tokio::test]
1877 async fn update_course_round_trips_ai_policy_fields() {
1878 let app_config =
1879 init_app_conf().expect("Application Configuration initialization failed");
1880 insert_data!(:tx, user: _user, :org);
1881 let course_language_group_id = course_language_groups::insert(
1882 tx.as_mut(),
1883 PKeyPolicy::Fixed(Uuid::parse_str("a1b2c3d4-0000-0000-0000-000000000001").unwrap()),
1884 "test-clg-ai-policy",
1885 )
1886 .await
1887 .unwrap();
1888 let new_course = NewCourse {
1889 name: "AI policy course".to_string(),
1890 slug: "ai-policy-course".to_string(),
1891 organization_id: org,
1892 language_code: "en-US".to_string(),
1893 teacher_in_charge_name: "teacher".to_string(),
1894 teacher_in_charge_email: "teacher@example.com".to_string(),
1895 description: "description".to_string(),
1896 is_draft: false,
1897 is_test_mode: false,
1898 is_unlisted: false,
1899 copy_user_permissions: false,
1900 is_joinable_by_code_only: false,
1901 join_code: None,
1902 ask_marketing_consent: false,
1903 flagged_answers_threshold: Some(3),
1904 can_add_chatbot: false,
1905 };
1906 let course_id = courses::insert(
1907 tx.as_mut(),
1908 &app_config,
1909 PKeyPolicy::Fixed(Uuid::parse_str("a1b2c3d4-0000-0000-0000-000000000002").unwrap()),
1910 course_language_group_id,
1911 &new_course,
1912 )
1913 .await
1914 .unwrap();
1915
1916 let created = courses::get_course(tx.as_mut(), course_id).await.unwrap();
1918 assert_eq!(created.ai_policy, CourseAiPolicy::NotSet);
1919 assert_eq!(created.course_material_ai_instructions, None);
1920
1921 let updated = courses::update_course(
1923 tx.as_mut(),
1924 &app_config,
1925 course_id,
1926 CourseUpdate {
1927 name: created.name.clone(),
1928 flagged_answers_threshold: 3,
1929 ai_policy: CourseAiPolicy::Limited,
1930 course_material_ai_instructions: Some(true),
1931 ..Default::default()
1932 },
1933 )
1934 .await
1935 .unwrap();
1936 assert_eq!(updated.ai_policy, CourseAiPolicy::Limited);
1937 assert_eq!(updated.course_material_ai_instructions, Some(true));
1938
1939 let reread = courses::get_course(tx.as_mut(), course_id).await.unwrap();
1941 assert_eq!(reread.ai_policy, CourseAiPolicy::Limited);
1942 assert_eq!(reread.course_material_ai_instructions, Some(true));
1943 }
1944 }
1945}