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