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 entity_history::{self, HistoryAuthor},
12 error::missing_model_error,
13 library::entity_snapshots::{
14 self, ContentEntityType, CourseAudienceSnapshot, CourseSettingsSnapshot,
15 },
16 organizations::DatabaseOrganization,
17 pages::{Page, PageVisibility, get_all_by_course_id_and_visibility},
18 prelude::*,
19};
20use headless_lms_utils::{
21 azure_embedding::create_embeddings, file_store::FileStore,
22 language_tag_to_name::LANGUAGE_TAG_TO_NAME, strings::non_empty_trimmed,
23};
24use pgvector::Vector;
25use utoipa::ToSchema;
26pub struct CourseInfo {
27 pub id: Uuid,
28 pub is_draft: bool,
29}
30
31#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
32
33pub struct CourseCount {
34 pub count: u32,
35}
36
37pub struct CourseContextData {
38 pub id: Uuid,
39 pub is_test_mode: bool,
40}
41
42#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
43pub struct CourseMetadata {
44 course_description: Option<String>,
45 course_audiences: Vec<CourseAudience>,
46 course_prerequisites: Vec<CoursePrerequisite>,
47 course_updated_at: DateTime<Utc>,
48}
49
50#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
51pub struct CourseMetadataUpdate {
52 course_description: Option<String>,
53 course_audiences: Vec<EditCourseAudience>,
54 course_prerequisites: Vec<EditCoursePrerequisite>,
55}
56
57#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
58pub struct CompleteCourseMetadata {
59 course: Course,
60 course_instances: Vec<CourseInstance>,
61 default_module: CourseModule,
62 course_prerequisites: Vec<CoursePrerequisite>,
63 course_audiences: Vec<CourseAudience>,
64 course_organization: DatabaseOrganization,
65}
66
67#[derive(
70 Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default, sqlx::Type, ToSchema,
71)]
72#[sqlx(type_name = "course_ai_policy", rename_all = "snake_case")]
73pub enum CourseAiPolicy {
74 #[default]
76 NotSet,
77 NoAi,
79 PlanningOnly,
81 Limited,
83 FullUse,
85 Required,
87}
88
89#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
90
91pub struct Course {
92 pub id: Uuid,
93 pub slug: String,
94 pub created_at: DateTime<Utc>,
95 pub updated_at: DateTime<Utc>,
96 pub name: String,
97 pub description: Option<String>,
98 pub organization_id: Uuid,
99 pub deleted_at: Option<DateTime<Utc>>,
100 pub language_code: String,
101 pub copied_from: Option<Uuid>,
102 pub content_search_language: Option<String>,
103 pub course_language_group_id: Uuid,
104 pub is_draft: bool,
105 pub is_test_mode: bool,
106 pub is_unlisted: bool,
107 pub base_module_completion_requires_n_submodule_completions: i32,
108 pub can_add_chatbot: bool,
109 pub is_joinable_by_code_only: bool,
110 pub join_code: Option<String>,
111 pub ask_marketing_consent: bool,
112 pub flagged_answers_threshold: Option<i32>,
113 pub flagged_answers_skip_manual_review_and_allow_retry: bool,
114 pub closed_at: Option<DateTime<Utc>>,
115 pub closed_additional_message: Option<String>,
116 pub closed_course_successor_id: Option<Uuid>,
117 pub chapter_locking_enabled: bool,
118 pub cheater_detection_enabled: bool,
119 pub ai_policy: CourseAiPolicy,
120 pub course_material_ai_instructions: Option<bool>,
121}
122impl Course {
123 pub fn is_closed(&self) -> bool {
124 if let Some(closed_at) = self.closed_at {
125 let now = chrono::Utc::now();
126 closed_at <= now
127 } else {
128 false
129 }
130 }
131}
132
133#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
135
136pub struct CourseMaterialCourse {
137 pub id: Uuid,
138 pub slug: String,
139 pub name: String,
140 pub description: Option<String>,
141 pub organization_id: Uuid,
142 pub language_code: String,
143 pub copied_from: Option<Uuid>,
144 pub content_search_language: Option<String>,
145 pub course_language_group_id: Uuid,
146 pub is_draft: bool,
147 pub is_test_mode: bool,
148 pub is_unlisted: bool,
149 pub base_module_completion_requires_n_submodule_completions: i32,
150 pub is_joinable_by_code_only: bool,
151 pub ask_marketing_consent: bool,
152 pub closed_at: Option<DateTime<Utc>>,
153 pub closed_additional_message: Option<String>,
154 pub closed_course_successor_id: Option<Uuid>,
155 pub chapter_locking_enabled: bool,
156 pub ai_policy: CourseAiPolicy,
157 pub course_material_ai_instructions: Option<bool>,
158}
159
160impl From<Course> for CourseMaterialCourse {
161 fn from(course: Course) -> Self {
162 CourseMaterialCourse {
163 id: course.id,
164 slug: course.slug,
165 name: course.name,
166 description: course.description,
167 organization_id: course.organization_id,
168 language_code: course.language_code,
169 copied_from: course.copied_from,
170 content_search_language: course.content_search_language,
171 course_language_group_id: course.course_language_group_id,
172 is_draft: course.is_draft,
173 is_test_mode: course.is_test_mode,
174 is_unlisted: course.is_unlisted,
175 base_module_completion_requires_n_submodule_completions: course
176 .base_module_completion_requires_n_submodule_completions,
177 is_joinable_by_code_only: course.is_joinable_by_code_only,
178 ask_marketing_consent: course.ask_marketing_consent,
179 closed_at: course.closed_at,
180 closed_additional_message: course.closed_additional_message,
181 closed_course_successor_id: course.closed_course_successor_id,
182 chapter_locking_enabled: course.chapter_locking_enabled,
183 ai_policy: course.ai_policy,
184 course_material_ai_instructions: course.course_material_ai_instructions,
185 }
186 }
187}
188
189#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
191
192pub struct CourseLanguageVersionNavigationInfo {
193 pub course_language_group_id: Uuid,
194 pub course_id: Uuid,
195 pub language_code: String,
196 pub course_slug: String,
197 pub page_path: String,
198 pub is_draft: bool,
199 pub current_page_unavailable_in_this_language: bool,
200}
201
202impl CourseLanguageVersionNavigationInfo {
203 pub fn from_course_and_page_info(
205 course: &Course,
206 page_info: Option<&crate::page_language_groups::PageLanguageGroupNavigationInfo>,
207 ) -> Self {
208 Self {
209 course_language_group_id: course.course_language_group_id,
210 course_id: course.id,
211 language_code: course.language_code.clone(),
212 course_slug: course.slug.clone(),
213 page_path: page_info
214 .map(|p| p.page_path.clone())
215 .unwrap_or_else(|| "/".to_string()),
216 is_draft: course.is_draft,
217 current_page_unavailable_in_this_language: page_info.is_none(),
218 }
219 }
220}
221
222#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
223
224pub struct CourseBreadcrumbInfo {
225 pub course_id: Uuid,
226 pub course_name: String,
227 pub course_slug: String,
228 pub organization_slug: String,
229 pub organization_name: String,
230}
231
232#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
234
235pub struct NewCourse {
236 pub name: String,
237 pub slug: String,
238 pub organization_id: Uuid,
239 pub language_code: String,
240 pub teacher_in_charge_name: String,
242 pub teacher_in_charge_email: String,
244 pub description: String,
245 pub is_draft: bool,
246 pub is_test_mode: bool,
247 pub is_unlisted: bool,
248 pub copy_user_permissions: bool,
250 pub is_joinable_by_code_only: bool,
251 pub join_code: Option<String>,
252 pub ask_marketing_consent: bool,
253 pub flagged_answers_threshold: Option<i32>,
254 pub can_add_chatbot: bool,
255}
256
257#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
258
259pub struct CourseAuditingData {
260 pub id: Uuid,
261 pub name: String,
262 pub slug: String,
263 pub created_at: DateTime<Utc>,
264 pub updated_at: DateTime<Utc>,
265 pub description: Option<String>,
266 pub is_draft: bool,
267 pub is_test_mode: bool,
268 pub is_unlisted: bool,
269 pub is_joinable_by_code_only: bool,
270 pub closed_at: Option<DateTime<Utc>>,
271 pub closed_additional_message: Option<String>,
272 pub closed_course_successor_id: Option<Uuid>,
273 pub organization_id: Uuid,
274 pub organization_name: String,
275 pub organization_slug: String,
276 pub prerequisites: Vec<EditCoursePrerequisite>,
277 pub audiences: Vec<EditCourseAudience>,
278 pub modules: Vec<CourseModule>,
279}
280
281#[derive(Debug, Deserialize, ToSchema)]
282
283pub struct CourseAuditingDataUpdate {
284 pub description: Option<String>,
285 pub is_draft: bool,
286 pub is_test_mode: bool,
287 pub is_unlisted: bool,
288 pub is_joinable_by_code_only: bool,
289 pub closed_at: Option<DateTime<Utc>>,
290 pub closed_additional_message: Option<String>,
291 pub closed_course_successor_id: Option<Uuid>,
292 pub prerequisites: Vec<EditCoursePrerequisite>,
293 pub audiences: Vec<EditCourseAudience>,
294 pub modules: Vec<CourseAuditingModuleUpdate>,
295}
296
297pub async fn insert(
298 conn: &mut PgConnection,
299 app_config: &ApplicationConfiguration,
300 pkey_policy: PKeyPolicy<Uuid>,
301 course_language_group_id: Uuid,
302 new_course: &NewCourse,
303) -> ModelResult<Uuid> {
304 let title = non_empty_trimmed(&new_course.name);
305 let name = title.unwrap_or(&new_course.name);
306 let description = non_empty_trimmed(&new_course.description);
307 let embeddings = if app_config.disable_embedding_vector_creation_when_seeding {
308 None
309 } else {
310 let texts = title.into_iter().chain(description).map(str::to_string);
311 let embeddings = TextEmbeddings::create(app_config, texts.collect()).await?;
312 Some((
313 embeddings.get_or_create_one(app_config, title).await?,
314 embeddings
315 .get_or_create_one(app_config, description)
316 .await?,
317 ))
318 };
319
320 let mut tx = conn.begin().await?;
321
322 let res = sqlx::query!(
323 "
324INSERT INTO courses(
325 id,
326 name,
327 description,
328 slug,
329 organization_id,
330 language_code,
331 course_language_group_id,
332 is_draft,
333 is_test_mode,
334 is_joinable_by_code_only,
335 join_code,
336 can_add_chatbot
337 )
338VALUES(
339 $1,
340 $2,
341 $3,
342 $4,
343 $5,
344 $6,
345 $7,
346 $8,
347 $9,
348 $10,
349 $11,
350 $12
351 )
352RETURNING id
353 ",
354 pkey_policy.into_uuid(),
355 name,
356 description,
357 new_course.slug,
358 new_course.organization_id,
359 new_course.language_code,
360 course_language_group_id,
361 new_course.is_draft,
362 new_course.is_test_mode,
363 new_course.is_joinable_by_code_only,
364 new_course.join_code,
365 new_course.can_add_chatbot,
366 )
367 .fetch_one(&mut *tx)
368 .await?;
369 if let Some((title_embedding, description_embedding)) = embeddings {
370 update_course_embeddings(&mut tx, res.id, title_embedding, description_embedding).await?
371 }
372 tx.commit().await?;
373 Ok(res.id)
374}
375
376#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
377
378pub struct CourseStructure {
379 pub course: Course,
380 pub pages: Vec<Page>,
381 pub chapters: Vec<Chapter>,
382 pub modules: Vec<CourseModule>,
383}
384
385pub async fn all_courses(conn: &mut PgConnection) -> ModelResult<Vec<Course>> {
386 let courses = sqlx::query_as!(
387 Course,
388 r#"
389SELECT *
390FROM courses
391WHERE deleted_at IS NULL;
392"#
393 )
394 .fetch_all(conn)
395 .await?;
396 Ok(courses)
397}
398
399pub async fn all_courses_for_auditing(
400 conn: &mut PgConnection,
401) -> ModelResult<Vec<CourseAuditingData>> {
402 let courses_data = sqlx::query!(
403 r#"
404SELECT c.id,
405 c.name,
406 c.slug,
407 c.created_at,
408 c.updated_at,
409 c.organization_id,
410 c.description,
411 c.is_draft,
412 c.is_test_mode,
413 c.is_unlisted,
414 c.is_joinable_by_code_only,
415 c.closed_at,
416 c.closed_additional_message,
417 c.closed_course_successor_id,
418 o.name AS organization_name,
419 o.slug AS organization_slug
420FROM courses c
421 JOIN organizations o ON o.id = c.organization_id
422WHERE c.deleted_at IS NULL
423 AND o.deleted_at IS NULL
424GROUP BY c.id,
425 c.name,
426 o.name,
427 o.slug
428"#
429 )
430 .fetch_all(&mut *conn)
431 .await?;
432
433 let modules_data = crate::course_modules::get_all_modules(conn).await?;
434
435 let prerequisites_data =
436 crate::course_prerequisites::get_all_edit_course_prerequisites(conn).await?;
437
438 let audiences_data = crate::course_audiences::get_all_edit_course_audiences(conn).await?;
439
440 let mut modules_by_course_id: HashMap<Uuid, Vec<CourseModule>> = HashMap::new();
441 for module in modules_data {
442 modules_by_course_id
443 .entry(module.course_id)
444 .or_default()
445 .push(module);
446 }
447
448 for course_modules in modules_by_course_id.values_mut() {
449 course_modules.sort_by_key(|c| c.order_number);
450 }
451
452 let mut prerequisites_by_course_id: HashMap<Uuid, Vec<EditCoursePrerequisite>> = HashMap::new();
453 for prerequisite in prerequisites_data {
454 prerequisites_by_course_id
455 .entry(prerequisite.course_id)
456 .or_default()
457 .push(prerequisite);
458 }
459
460 let mut audiences_by_course_id: HashMap<Uuid, Vec<EditCourseAudience>> = HashMap::new();
461 for audience in audiences_data {
462 audiences_by_course_id
463 .entry(audience.course_id)
464 .or_default()
465 .push(audience);
466 }
467
468 let data: Vec<CourseAuditingData> = courses_data
469 .into_iter()
470 .map(|c| CourseAuditingData {
471 id: c.id,
472 name: c.name,
473 slug: c.slug,
474 created_at: c.created_at,
475 updated_at: c.updated_at,
476 organization_id: c.organization_id,
477 description: c.description,
478 is_draft: c.is_draft,
479 is_test_mode: c.is_test_mode,
480 is_unlisted: c.is_unlisted,
481 is_joinable_by_code_only: c.is_joinable_by_code_only,
482 closed_at: c.closed_at,
483 closed_additional_message: c.closed_additional_message,
484 closed_course_successor_id: c.closed_course_successor_id,
485 organization_name: c.organization_name,
486 organization_slug: c.organization_slug,
487 modules: modules_by_course_id.remove(&c.id).unwrap_or_default(),
488 prerequisites: prerequisites_by_course_id.remove(&c.id).unwrap_or_default(),
489 audiences: audiences_by_course_id.remove(&c.id).unwrap_or_default(),
490 })
491 .collect();
492
493 Ok(data)
494}
495
496pub async fn course_auditing_data_by_id(
497 conn: &mut PgConnection,
498 course_id: Uuid,
499) -> ModelResult<CourseAuditingData> {
500 let course_data = sqlx::query!(
501 r#"
502SELECT c.id,
503 c.name,
504 c.slug,
505 c.created_at,
506 c.updated_at,
507 c.organization_id,
508 c.description,
509 c.is_draft,
510 c.is_test_mode,
511 c.is_unlisted,
512 c.is_joinable_by_code_only,
513 c.closed_at,
514 c.closed_additional_message,
515 c.closed_course_successor_id,
516 o.name AS organization_name,
517 o.slug AS organization_slug
518FROM courses c
519 JOIN organizations o ON o.id = c.organization_id
520WHERE c.id = $1
521 AND c.deleted_at IS NULL
522 AND o.deleted_at IS NULL
523GROUP BY c.id,
524 c.name,
525 o.name,
526 o.slug
527"#,
528 course_id
529 )
530 .fetch_one(&mut *conn)
531 .await?;
532
533 let mut modules_data = crate::course_modules::get_by_course_id(conn, course_id).await?;
534
535 modules_data.sort_by_key(|c| c.order_number);
536
537 let prerequisites_data =
538 crate::course_prerequisites::get_edit_course_prerequisites_by_course_id(conn, course_id)
539 .await?;
540
541 let audiences_data =
542 crate::course_audiences::get_edit_course_audiences_by_course_id(conn, course_id).await?;
543
544 let data: CourseAuditingData = CourseAuditingData {
545 id: course_data.id,
546 name: course_data.name,
547 slug: course_data.slug,
548 created_at: course_data.created_at,
549 updated_at: course_data.updated_at,
550 organization_id: course_data.organization_id,
551 description: course_data.description,
552 is_draft: course_data.is_draft,
553 is_test_mode: course_data.is_test_mode,
554 is_unlisted: course_data.is_unlisted,
555 is_joinable_by_code_only: course_data.is_joinable_by_code_only,
556 closed_at: course_data.closed_at,
557 closed_additional_message: course_data.closed_additional_message,
558 closed_course_successor_id: course_data.closed_course_successor_id,
559 organization_name: course_data.organization_name,
560 organization_slug: course_data.organization_slug,
561 modules: modules_data,
562 prerequisites: prerequisites_data,
563 audiences: audiences_data,
564 };
565
566 Ok(data)
567}
568
569pub async fn update_course_auditing_data(
572 conn: &mut PgConnection,
573 app_config: &ApplicationConfiguration,
574 course_id: Uuid,
575 data_update: CourseAuditingDataUpdate,
576 author_user_id: Uuid,
577) -> ModelResult<()> {
578 let CourseAuditingDataUpdate {
580 description,
581 is_draft,
582 is_test_mode,
583 is_unlisted,
584 is_joinable_by_code_only,
585 closed_at,
586 closed_additional_message,
587 closed_course_successor_id,
588 prerequisites,
589 audiences,
590 modules,
591 } = data_update;
592 let author = HistoryAuthor::user(author_user_id);
593 let audiences = audience_snapshots(audiences);
594 let edit = |live: &CourseSettingsSnapshot| CourseSettingsSnapshot {
595 description: description.clone(),
596 is_draft,
597 is_test_mode,
598 is_unlisted,
599 is_joinable_by_code_only,
600 closed_at,
601 closed_additional_message: closed_additional_message.clone(),
602 closed_course_successor_id,
603 audiences: audiences.clone(),
604 ..live.clone()
605 };
606 let embeddings =
607 embed_changed_metadata(conn, app_config, course_id, &edit, &prerequisites).await?;
608 let mut tx = conn.begin().await?;
609 let live = load_settings(&mut tx, course_id).await?;
610 write_settings(&mut tx, app_config, &edit(&live), &live, false, &embeddings).await?;
611 write_prerequisites(&mut tx, app_config, course_id, prerequisites, &embeddings).await?;
612 entity_history::record_current(
613 &mut tx,
614 ContentEntityType::CourseSettings,
615 course_id,
616 author,
617 )
618 .await?;
619
620 for module_id in
621 crate::course_modules::update_auditing_fields(&mut tx, course_id, modules).await?
622 {
623 entity_history::record_current(&mut tx, ContentEntityType::Module, module_id, author)
624 .await?;
625 }
626
627 tx.commit().await?;
628 Ok(())
629}
630
631pub async fn all_courses_user_enrolled_to(
632 conn: &mut PgConnection,
633 user_id: Uuid,
634) -> ModelResult<Vec<Course>> {
635 let courses = sqlx::query_as!(
636 Course,
637 r#"
638SELECT *
639FROM courses
640WHERE courses.deleted_at IS NULL
641 AND id IN (
642 SELECT current_course_id
643 FROM user_course_settings
644 WHERE deleted_at IS NULL
645 AND user_id = $1
646 )
647"#,
648 user_id
649 )
650 .fetch_all(conn)
651 .await?;
652 Ok(courses)
653}
654
655#[derive(Debug)]
657pub struct CourseWithOrganizationName {
658 pub id: Uuid,
659 pub slug: String,
660 pub name: String,
661 pub description: Option<String>,
662 pub ai_policy: CourseAiPolicy,
663 pub organization_name: String,
664}
665
666pub async fn get_enrolled_courses_with_exercise_types(
670 conn: &mut PgConnection,
671 user_id: Uuid,
672 exercise_types: &[String],
673) -> ModelResult<Vec<CourseWithOrganizationName>> {
674 let courses = sqlx::query_as!(
675 CourseWithOrganizationName,
676 r#"
677SELECT c.id,
678 c.slug,
679 c.name,
680 c.description,
681 c.ai_policy AS "ai_policy: CourseAiPolicy",
682 o.name AS organization_name
683FROM courses AS c
684 JOIN organizations AS o ON o.id = c.organization_id
685WHERE c.deleted_at IS NULL
686 AND o.deleted_at IS NULL
687 AND EXISTS (
688 SELECT 1
689 FROM course_instance_enrollments AS cie
690 JOIN course_instances AS ci ON ci.id = cie.course_instance_id
691 WHERE cie.user_id = $1
692 AND ci.course_id = c.id
693 AND cie.deleted_at IS NULL
694 AND ci.deleted_at IS NULL
695 )
696 AND EXISTS (
697 SELECT 1
698 FROM exercises AS e
699 JOIN exercise_slides AS es ON es.exercise_id = e.id
700 JOIN exercise_tasks AS et ON et.exercise_slide_id = es.id
701 WHERE e.course_id = c.id
702 AND et.exercise_type = ANY($2)
703 AND e.deleted_at IS NULL
704 AND es.deleted_at IS NULL
705 AND et.deleted_at IS NULL
706 )
707ORDER BY c.name,
708 c.id
709"#,
710 user_id,
711 exercise_types,
712 )
713 .fetch_all(conn)
714 .await?;
715 Ok(courses)
716}
717
718pub async fn all_courses_with_roles_for_user(
719 conn: &mut PgConnection,
720 user_id: Uuid,
721) -> ModelResult<Vec<Course>> {
722 let courses = sqlx::query_as!(
723 Course,
724 r#"
725SELECT *
726FROM courses
727WHERE courses.deleted_at IS NULL
728 AND (
729 id IN (
730 SELECT course_id
731 FROM roles
732 WHERE deleted_at IS NULL
733 AND user_id = $1
734 AND course_id IS NOT NULL
735 )
736 OR (
737 id IN (
738 SELECT ci.course_id
739 FROM course_instances ci
740 JOIN ROLES r ON r.course_instance_id = ci.id
741 WHERE r.user_id = $1
742 AND r.deleted_at IS NULL
743 AND ci.deleted_at IS NULL
744 )
745 )
746 ) "#,
747 user_id
748 )
749 .fetch_all(conn)
750 .await?;
751 Ok(courses)
752}
753
754pub async fn get_all_language_versions_of_course(
755 conn: &mut PgConnection,
756 course: &Course,
757) -> ModelResult<Vec<Course>> {
758 let courses = sqlx::query_as!(
759 Course,
760 r#"
761SELECT *
762FROM courses
763WHERE course_language_group_id = $1
764AND deleted_at IS NULL
765 "#,
766 course.course_language_group_id,
767 )
768 .fetch_all(conn)
769 .await?;
770 Ok(courses)
771}
772
773pub async fn get_by_course_language_group_ids(
776 conn: &mut PgConnection,
777 course_language_group_ids: &[Uuid],
778) -> ModelResult<Vec<Course>> {
779 let courses = sqlx::query_as!(
780 Course,
781 r#"
782SELECT *
783FROM courses
784WHERE course_language_group_id = ANY($1)
785 AND deleted_at IS NULL
786 "#,
787 course_language_group_ids,
788 )
789 .fetch_all(conn)
790 .await?;
791 Ok(courses)
792}
793
794pub async fn get_active_courses_for_organization(
795 conn: &mut PgConnection,
796 organization_id: Uuid,
797 pagination: Pagination,
798) -> ModelResult<Vec<Course>> {
799 let course_instances = sqlx::query_as!(
800 Course,
801 r#"
802SELECT
803 DISTINCT(c.id),
804 c.name,
805 c.created_at,
806 c.updated_at,
807 c.organization_id,
808 c.deleted_at,
809 c.slug,
810 c.content_search_language::text,
811 c.language_code,
812 c.copied_from,
813 c.course_language_group_id,
814 c.description,
815 c.is_draft,
816 c.is_test_mode,
817 c.base_module_completion_requires_n_submodule_completions,
818 c.can_add_chatbot,
819 c.is_unlisted,
820 c.is_joinable_by_code_only,
821 c.join_code,
822 c.ask_marketing_consent,
823 c.flagged_answers_threshold,
824 c.flagged_answers_skip_manual_review_and_allow_retry,
825 c.closed_at,
826 c.closed_additional_message,
827 c.closed_course_successor_id,
828 c.chapter_locking_enabled,
829 c.cheater_detection_enabled,
830 c.ai_policy,
831 c.course_material_ai_instructions
832FROM courses as c
833 LEFT JOIN course_instances as ci on c.id = ci.course_id
834WHERE
835 c.organization_id = $1 AND
836 ci.starts_at < NOW() AND ci.ends_at > NOW() AND
837 c.deleted_at IS NULL AND ci.deleted_at IS NULL
838 LIMIT $2 OFFSET $3;
839 "#,
840 organization_id,
841 pagination.limit(),
842 pagination.offset()
843 )
844 .fetch_all(conn)
845 .await?;
846 Ok(course_instances)
847}
848
849pub async fn get_active_courses_for_organization_count(
850 conn: &mut PgConnection,
851 organization_id: Uuid,
852) -> ModelResult<CourseCount> {
853 let result = sqlx::query!(
854 r#"
855SELECT
856 COUNT(DISTINCT c.id) as count
857FROM courses as c
858 LEFT JOIN course_instances as ci on c.id = ci.course_id
859WHERE
860 c.organization_id = $1 AND
861 ci.starts_at < NOW() AND ci.ends_at > NOW() AND
862 c.deleted_at IS NULL AND ci.deleted_at IS NULL;
863 "#,
864 organization_id
865 )
866 .fetch_one(conn)
867 .await?;
868 Ok(CourseCount {
869 count: result.count.unwrap_or_default().try_into()?,
870 })
871}
872
873pub async fn get_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
874 let course = sqlx::query_as!(
875 Course,
876 r#"
877SELECT *
878FROM courses
879WHERE id = $1
880 AND deleted_at IS NULL;
881 "#,
882 course_id
883 )
884 .fetch_one(conn)
885 .await?;
886 Ok(course)
887}
888
889pub async fn get_by_id_and_join_code(
890 conn: &mut PgConnection,
891 course_id: Uuid,
892 join_code: &str,
893) -> ModelResult<Course> {
894 let course = sqlx::query_as!(
895 Course,
896 r#"
897SELECT *
898FROM courses
899WHERE id = $1
900 AND join_code = $2
901 AND deleted_at IS NULL;
902 "#,
903 course_id,
904 join_code,
905 )
906 .fetch_one(conn)
907 .await?;
908 Ok(course)
909}
910
911pub async fn get_course_breadcrumb_info(
912 conn: &mut PgConnection,
913 course_id: Uuid,
914) -> ModelResult<CourseBreadcrumbInfo> {
915 let res = sqlx::query_as!(
916 CourseBreadcrumbInfo,
917 r#"
918SELECT courses.id as course_id,
919 courses.name as course_name,
920 courses.slug as course_slug,
921 organizations.slug as organization_slug,
922 organizations.name as organization_name
923FROM courses
924 JOIN organizations ON (courses.organization_id = organizations.id)
925WHERE courses.id = $1
926 AND courses.deleted_at IS NULL;
927 "#,
928 course_id
929 )
930 .fetch_one(conn)
931 .await?;
932 Ok(res)
933}
934
935pub async fn get_nondeleted_course_id_by_slug(
936 conn: &mut PgConnection,
937 slug: &str,
938) -> ModelResult<CourseContextData> {
939 let data = sqlx::query_as!(
940 CourseContextData,
941 "SELECT id, is_test_mode FROM courses WHERE slug = $1 AND deleted_at IS NULL",
942 slug
943 )
944 .fetch_one(conn)
945 .await?;
946 Ok(data)
947}
948
949pub async fn get_active_course_id_by_slug(
951 conn: &mut PgConnection,
952 slug: &str,
953) -> ModelResult<Option<Uuid>> {
954 let id = sqlx::query_scalar!(
955 "SELECT id FROM courses WHERE slug = $1 AND deleted_at IS NULL",
956 slug
957 )
958 .fetch_optional(conn)
959 .await?;
960 Ok(id)
961}
962
963pub async fn get_organization_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
964 let organization_id = sqlx::query!("SELECT organization_id FROM courses WHERE id = $1", id)
965 .fetch_one(conn)
966 .await?
967 .organization_id;
968 Ok(organization_id)
969}
970
971pub async fn get_course_structure(
973 conn: &mut PgConnection,
974 course_id: Uuid,
975 file_store: &dyn FileStore,
976 app_conf: &ApplicationConfiguration,
977) -> ModelResult<CourseStructure> {
978 let course = get_course(conn, course_id).await?;
979 let pages = get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
980 let chapters = get_course_chapters(conn, course_id)
981 .await?
982 .iter()
983 .map(|chapter| Chapter::from_database_chapter(chapter, file_store, app_conf))
984 .collect();
985 let modules = crate::course_modules::get_by_course_id(conn, course_id).await?;
986 Ok(CourseStructure {
987 course,
988 pages,
989 chapters,
990 modules,
991 })
992}
993
994pub async fn organization_courses_visible_to_user_paginated(
995 conn: &mut PgConnection,
996 organization_id: Uuid,
997 user: Option<Uuid>,
998 pagination: Pagination,
999) -> ModelResult<Vec<Course>> {
1000 let courses = sqlx::query_as!(
1001 Course,
1002 r#"
1003SELECT courses.id,
1004 courses.name,
1005 courses.created_at,
1006 courses.updated_at,
1007 courses.organization_id,
1008 courses.deleted_at,
1009 courses.slug,
1010 courses.content_search_language::text,
1011 courses.language_code,
1012 courses.copied_from,
1013 courses.course_language_group_id,
1014 courses.description,
1015 courses.is_draft,
1016 courses.is_test_mode,
1017 base_module_completion_requires_n_submodule_completions,
1018 can_add_chatbot,
1019 courses.is_unlisted,
1020 courses.is_joinable_by_code_only,
1021 courses.join_code,
1022 courses.ask_marketing_consent,
1023 courses.flagged_answers_threshold,
1024 courses.flagged_answers_skip_manual_review_and_allow_retry,
1025 courses.closed_at,
1026 courses.closed_additional_message,
1027 courses.closed_course_successor_id,
1028 courses.chapter_locking_enabled,
1029 courses.cheater_detection_enabled,
1030 courses.ai_policy,
1031 courses.course_material_ai_instructions
1032FROM courses
1033WHERE courses.organization_id = $1
1034 AND (
1035 (
1036 courses.is_draft IS FALSE
1037 AND courses.is_unlisted IS FALSE
1038 )
1039 OR EXISTS (
1040 SELECT id
1041 FROM roles
1042 WHERE user_id = $2
1043 AND (
1044 course_id = courses.id
1045 OR roles.organization_id = courses.organization_id
1046 OR roles.is_global IS TRUE
1047 )
1048 )
1049 )
1050 AND courses.deleted_at IS NULL
1051ORDER BY courses.name
1052LIMIT $3 OFFSET $4;
1053"#,
1054 organization_id,
1055 user,
1056 pagination.limit(),
1057 pagination.offset()
1058 )
1059 .fetch_all(conn)
1060 .await?;
1061 Ok(courses)
1062}
1063
1064pub async fn organization_course_count(
1065 conn: &mut PgConnection,
1066 organization_id: Uuid,
1067) -> ModelResult<CourseCount> {
1068 let course_count = sqlx::query!(
1069 r#"
1070SELECT
1071 COUNT(DISTINCT id) as count
1072FROM courses
1073WHERE organization_id = $1
1074 AND deleted_at IS NULL;
1075 "#,
1076 organization_id,
1077 )
1078 .fetch_one(conn)
1079 .await?;
1080 Ok(CourseCount {
1081 count: course_count.count.unwrap_or_default().try_into()?,
1082 })
1083}
1084#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default, ToSchema)]
1086
1087pub struct CourseUpdate {
1088 pub name: String,
1089 pub description: Option<String>,
1090 pub is_draft: bool,
1091 pub is_test_mode: bool,
1092 pub can_add_chatbot: bool,
1093 pub is_unlisted: bool,
1094 pub is_joinable_by_code_only: bool,
1095 pub ask_marketing_consent: bool,
1096 pub flagged_answers_threshold: i32,
1097 pub flagged_answers_skip_manual_review_and_allow_retry: bool,
1098 pub closed_at: Option<DateTime<Utc>>,
1099 pub closed_additional_message: Option<String>,
1100 pub closed_course_successor_id: Option<Uuid>,
1101 pub chapter_locking_enabled: bool,
1102 pub ai_policy: CourseAiPolicy,
1103 pub course_material_ai_instructions: Option<bool>,
1104}
1105
1106pub(crate) async fn defer_structure_constraints(conn: &mut PgConnection) -> ModelResult<()> {
1110 sqlx::query!(
1111 "SET CONSTRAINTS unique_chapters_chapter_number_course_id_when_not_deleted,
1112 course_modules_order_number_uniqueness,
1113 pages_order_number_uniqueness,
1114 unique_pages_url_path_course_id_when_not_deleted,
1115 citation_key_uniqueness,
1116 chapters_front_page_id_fkey DEFERRED"
1117 )
1118 .execute(conn)
1119 .await?;
1120 Ok(())
1121}
1122
1123impl From<&Course> for CourseUpdate {
1124 fn from(course: &Course) -> Self {
1126 Self {
1127 name: course.name.clone(),
1128 description: course.description.clone(),
1129 is_draft: course.is_draft,
1130 is_test_mode: course.is_test_mode,
1131 can_add_chatbot: course.can_add_chatbot,
1132 is_unlisted: course.is_unlisted,
1133 is_joinable_by_code_only: course.is_joinable_by_code_only,
1134 ask_marketing_consent: course.ask_marketing_consent,
1135 flagged_answers_threshold: course.flagged_answers_threshold.unwrap_or(0),
1136 flagged_answers_skip_manual_review_and_allow_retry: course
1137 .flagged_answers_skip_manual_review_and_allow_retry,
1138 closed_at: course.closed_at,
1139 closed_additional_message: course.closed_additional_message.clone(),
1140 closed_course_successor_id: course.closed_course_successor_id,
1141 chapter_locking_enabled: course.chapter_locking_enabled,
1142 ai_policy: course.ai_policy,
1143 course_material_ai_instructions: course.course_material_ai_instructions,
1144 }
1145 }
1146}
1147
1148pub async fn update_course(
1151 conn: &mut PgConnection,
1152 app_config: &ApplicationConfiguration,
1153 course_id: Uuid,
1154 course_update: CourseUpdate,
1155 author_user_id: Uuid,
1156 can_change_chatbot: bool,
1157) -> ModelResult<Course> {
1158 let CourseUpdate {
1160 name,
1161 description,
1162 is_draft,
1163 is_test_mode,
1164 can_add_chatbot,
1165 is_unlisted,
1166 is_joinable_by_code_only,
1167 ask_marketing_consent,
1168 flagged_answers_threshold,
1169 flagged_answers_skip_manual_review_and_allow_retry,
1170 closed_at,
1171 closed_additional_message,
1172 closed_course_successor_id,
1173 chapter_locking_enabled,
1174 ai_policy,
1175 course_material_ai_instructions,
1176 } = course_update;
1177 let edit = move |live: &CourseSettingsSnapshot| CourseSettingsSnapshot {
1178 name: name.clone(),
1179 description: description.clone(),
1180 is_draft,
1181 is_test_mode,
1182 can_add_chatbot,
1183 is_unlisted,
1184 is_joinable_by_code_only,
1185 ask_marketing_consent,
1186 flagged_answers_threshold: Some(flagged_answers_threshold),
1187 flagged_answers_skip_manual_review_and_allow_retry,
1188 closed_at,
1189 closed_additional_message: closed_additional_message.clone(),
1190 closed_course_successor_id,
1191 chapter_locking_enabled,
1192 ai_policy,
1193 course_material_ai_instructions,
1194 ..live.clone()
1195 };
1196 let live = load_settings(conn, course_id).await?;
1197 let embeddings =
1198 TextEmbeddings::create(app_config, changed_settings_texts(&edit(&live), &live)).await?;
1199 entity_history::save_recording(
1200 conn,
1201 ContentEntityType::CourseSettings,
1202 course_id,
1203 HistoryAuthor::user(author_user_id),
1204 async move |tx| {
1205 let live = load_settings(&mut *tx, course_id).await?;
1206 write_settings(
1207 tx,
1208 app_config,
1209 &edit(&live),
1210 &live,
1211 can_change_chatbot,
1212 &embeddings,
1213 )
1214 .await
1215 },
1216 )
1217 .await
1218}
1219
1220async fn load_settings(
1221 conn: &mut PgConnection,
1222 course_id: Uuid,
1223) -> ModelResult<CourseSettingsSnapshot> {
1224 let course = get_course(conn, course_id).await?;
1225 entity_snapshots::course_settings_of(conn, &course).await
1226}
1227
1228pub async fn write_settings(
1234 conn: &mut PgConnection,
1235 app_config: &ApplicationConfiguration,
1236 settings: &CourseSettingsSnapshot,
1237 live: &CourseSettingsSnapshot,
1238 can_change_chatbot: bool,
1239 embeddings: &TextEmbeddings,
1240) -> ModelResult<Course> {
1241 if settings.course_id != live.course_id {
1242 return Err(model_err!(
1243 PreconditionFailed,
1244 "Course settings snapshot is for another course".to_string()
1245 ));
1246 }
1247 if settings.can_add_chatbot != live.can_add_chatbot && !can_change_chatbot {
1248 return Err(model_err!(
1249 PreconditionFailed,
1250 "Only global admins can change whether the course can have a chatbot".to_string()
1251 ));
1252 }
1253 let course_id = settings.course_id;
1254 let mut tx = conn.begin().await?;
1255 let course = sqlx::query_as!(
1256 Course,
1257 r#"
1258UPDATE courses
1259SET name = $1,
1260 description = $2,
1261 is_draft = $3,
1262 is_test_mode = $4,
1263 can_add_chatbot = $5,
1264 is_unlisted = $6,
1265 is_joinable_by_code_only = $7,
1266 ask_marketing_consent = $8,
1267 flagged_answers_threshold = $9,
1268 flagged_answers_skip_manual_review_and_allow_retry = $10,
1269 closed_at = $11,
1270 closed_additional_message = $12,
1271 closed_course_successor_id = $13,
1272 chapter_locking_enabled = $14,
1273 ai_policy = $15,
1274 course_material_ai_instructions = $16,
1275 base_module_completion_requires_n_submodule_completions = $17
1276WHERE id = $18
1277 AND deleted_at IS NULL
1278RETURNING *
1279 "#,
1280 settings.name,
1281 settings.description,
1282 settings.is_draft,
1283 settings.is_test_mode,
1284 settings.can_add_chatbot,
1285 settings.is_unlisted,
1286 settings.is_joinable_by_code_only,
1287 settings.ask_marketing_consent,
1288 settings.flagged_answers_threshold,
1289 settings.flagged_answers_skip_manual_review_and_allow_retry,
1290 settings.closed_at,
1291 settings.closed_additional_message,
1292 settings.closed_course_successor_id,
1293 settings.chapter_locking_enabled,
1294 settings.ai_policy as CourseAiPolicy,
1295 settings.course_material_ai_instructions,
1296 settings.base_module_completion_requires_n_submodule_completions,
1297 course_id
1298 )
1299 .fetch_one(&mut *tx)
1300 .await?;
1301
1302 let (title, description) = changed_embedded_texts(settings, live);
1303 let title_embedding = embeddings.get_or_create_one(app_config, title).await?;
1304 let description_embedding = embeddings
1305 .get_or_create_one(app_config, description)
1306 .await?;
1307 update_course_embeddings(&mut tx, course_id, title_embedding, description_embedding).await?;
1308
1309 if !live.chapter_locking_enabled && settings.chapter_locking_enabled {
1310 let user_ids: Vec<Uuid> =
1311 crate::user_course_settings::get_all_by_course_id(&mut tx, course_id)
1312 .await?
1313 .into_iter()
1314 .map(|settings| settings.user_id)
1315 .collect();
1316 crate::user_chapter_locking_statuses::init_for_users(&mut tx, course_id, &user_ids).await?;
1317 }
1318
1319 let (changed_ids, changed_audiences) = changed_audiences(&settings.audiences, &live.audiences);
1320 if !changed_audiences.is_empty() {
1321 let embeddings = embeddings
1322 .get_or_create(app_config, &changed_audiences)
1323 .await?;
1324 upsert_course_audiences(
1325 &mut tx,
1326 course_id,
1327 changed_ids,
1328 changed_audiences,
1329 embeddings,
1330 )
1331 .await?;
1332 }
1333 let removed_audience_ids: Vec<Uuid> = live
1334 .audiences
1335 .iter()
1336 .filter(|live| !settings.audiences.iter().any(|a| a.id == live.id))
1337 .map(|live| live.id)
1338 .collect();
1339 crate::course_audiences::delete_batch(&mut tx, removed_audience_ids).await?;
1340
1341 tx.commit().await?;
1342 Ok(course)
1343}
1344
1345pub async fn set_cheater_detection_enabled(
1349 conn: &mut PgConnection,
1350 course_id: Uuid,
1351 enabled: bool,
1352) -> ModelResult<()> {
1353 sqlx::query!(
1354 "
1355UPDATE courses
1356SET cheater_detection_enabled = $1
1357WHERE id = $2
1358 AND deleted_at IS NULL
1359 ",
1360 enabled,
1361 course_id,
1362 )
1363 .execute(conn)
1364 .await?;
1365 Ok(())
1366}
1367
1368pub async fn update_course_base_module_completion_count_requirement(
1369 conn: &mut PgConnection,
1370 id: Uuid,
1371 base_module_completion_requires_n_submodule_completions: i32,
1372) -> ModelResult<bool> {
1373 let res = sqlx::query!(
1374 "
1375UPDATE courses
1376SET base_module_completion_requires_n_submodule_completions = $1
1377WHERE id = $2
1378 AND deleted_at IS NULL
1379 ",
1380 base_module_completion_requires_n_submodule_completions,
1381 id,
1382 )
1383 .execute(conn)
1384 .await?;
1385 Ok(res.rows_affected() > 0)
1386}
1387
1388pub async fn delete_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
1389 let deleted = sqlx::query_as!(
1390 Course,
1391 r#"
1392UPDATE courses
1393SET deleted_at = now()
1394WHERE id = $1
1395AND deleted_at IS NULL
1396RETURNING *
1397 "#,
1398 course_id
1399 )
1400 .fetch_one(conn)
1401 .await?;
1402 Ok(deleted)
1403}
1404
1405pub async fn get_course_by_slug(conn: &mut PgConnection, course_slug: &str) -> ModelResult<Course> {
1406 let course = sqlx::query_as!(
1407 Course,
1408 r#"
1409SELECT *
1410FROM courses
1411WHERE slug = $1
1412 AND deleted_at IS NULL
1413"#,
1414 course_slug,
1415 )
1416 .fetch_one(conn)
1417 .await?;
1418 Ok(course)
1419}
1420
1421pub async fn get_cfgname_by_tag(
1422 conn: &mut PgConnection,
1423 ietf_language_tag: String,
1424) -> ModelResult<String> {
1425 let tag = ietf_language_tag
1426 .split('-')
1427 .next()
1428 .unwrap_or_else(|| &ietf_language_tag[..]);
1429
1430 let lang_name = LANGUAGE_TAG_TO_NAME.get(&tag);
1431
1432 let name = sqlx::query!(
1433 "SELECT cfgname::text FROM pg_ts_config WHERE cfgname = $1",
1434 lang_name
1435 )
1436 .fetch_optional(conn)
1437 .await?;
1438
1439 let res = name
1440 .and_then(|n| n.cfgname)
1441 .unwrap_or_else(|| "simple".to_string());
1442
1443 Ok(res)
1444}
1445
1446pub async fn is_draft(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
1447 let res = sqlx::query!(
1448 "
1449SELECT is_draft
1450FROM courses
1451WHERE id = $1
1452",
1453 id
1454 )
1455 .fetch_one(conn)
1456 .await?;
1457 Ok(res.is_draft)
1458}
1459
1460pub async fn is_joinable_by_code_only(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
1461 let res = sqlx::query!(
1462 "
1463SELECT is_joinable_by_code_only
1464FROM courses
1465WHERE id = $1
1466",
1467 id
1468 )
1469 .fetch_one(conn)
1470 .await?;
1471 Ok(res.is_joinable_by_code_only)
1472}
1473
1474pub async fn get_by_ids(conn: &mut PgConnection, course_ids: &[Uuid]) -> ModelResult<Vec<Course>> {
1475 let courses = sqlx::query_as!(
1476 Course,
1477 r#"
1478SELECT *
1479FROM courses
1480WHERE id IN (SELECT * FROM UNNEST($1::uuid[]))
1481 AND deleted_at IS NULL
1482 "#,
1483 course_ids
1484 )
1485 .fetch_all(conn)
1486 .await?;
1487 Ok(courses)
1488}
1489
1490pub async fn search_courses_by_slug_or_name(
1493 conn: &mut PgConnection,
1494 query: &str,
1495 limit: i64,
1496) -> ModelResult<Vec<Course>> {
1497 let courses = sqlx::query_as!(
1498 Course,
1499 r#"
1500SELECT *
1501FROM courses
1502WHERE deleted_at IS NULL
1503 AND (LOWER(slug) = LOWER($1) OR name ILIKE '%' || $1 || '%' OR similarity(name, $1) > 0.1)
1504ORDER BY
1505 LOWER(slug) = LOWER($1) DESC,
1506 name ILIKE '%' || $1 || '%' DESC,
1507 similarity(name, $1) DESC
1508LIMIT $2
1509 "#,
1510 query,
1511 limit,
1512 )
1513 .fetch_all(conn)
1514 .await?;
1515 Ok(courses)
1516}
1517
1518pub async fn get_by_course_or_organization_role(
1521 conn: &mut PgConnection,
1522 user_id: Uuid,
1523) -> ModelResult<Vec<Course>> {
1524 let courses = sqlx::query_as!(
1525 Course,
1526 r#"
1527SELECT *
1528FROM courses
1529WHERE deleted_at IS NULL
1530 AND (
1531 id IN (
1532 SELECT course_id
1533 FROM roles
1534 WHERE deleted_at IS NULL
1535 AND user_id = $1
1536 AND course_id IS NOT NULL
1537 )
1538 OR organization_id IN (
1539 SELECT organization_id
1540 FROM roles
1541 WHERE deleted_at IS NULL
1542 AND user_id = $1
1543 AND organization_id IS NOT NULL
1544 )
1545 )
1546ORDER BY name COLLATE "C",
1547 id
1548 "#,
1549 user_id
1550 )
1551 .fetch_all(conn)
1552 .await?;
1553 Ok(courses)
1554}
1555
1556pub async fn get_by_organization_id(
1557 conn: &mut PgConnection,
1558 organization_id: Uuid,
1559) -> ModelResult<Vec<Course>> {
1560 let courses = sqlx::query_as!(
1561 Course,
1562 r#"
1563SELECT *
1564FROM courses
1565WHERE organization_id = $1
1566 AND deleted_at IS NULL
1567ORDER BY name
1568 "#,
1569 organization_id
1570 )
1571 .fetch_all(conn)
1572 .await?;
1573 Ok(courses)
1574}
1575
1576pub async fn set_join_code_for_course(
1577 conn: &mut PgConnection,
1578 course_id: Uuid,
1579 join_code: String,
1580) -> ModelResult<()> {
1581 sqlx::query!(
1582 "
1583UPDATE courses
1584SET join_code = $2
1585WHERE id = $1
1586",
1587 course_id,
1588 join_code
1589 )
1590 .execute(conn)
1591 .await?;
1592 Ok(())
1593}
1594
1595pub async fn get_course_with_join_code(
1596 conn: &mut PgConnection,
1597 join_code: String,
1598) -> ModelResult<Course> {
1599 let course = sqlx::query_as!(
1600 Course,
1601 r#"
1602SELECT *
1603FROM courses
1604WHERE join_code = $1
1605 AND deleted_at IS NULL;
1606 "#,
1607 join_code,
1608 )
1609 .fetch_one(conn)
1610 .await?;
1611 Ok(course)
1612}
1613
1614pub async fn set_metadata(
1617 conn: &mut PgConnection,
1618 app_config: &ApplicationConfiguration,
1619 course_id: Uuid,
1620 course_metadata: CourseMetadataUpdate,
1621 author_user_id: Uuid,
1622) -> ModelResult<CourseMetadata> {
1623 let CourseMetadataUpdate {
1624 course_description,
1625 course_audiences,
1626 course_prerequisites,
1627 } = course_metadata;
1628 let course_audiences = audience_snapshots(course_audiences);
1629 let edit = move |live: &CourseSettingsSnapshot| CourseSettingsSnapshot {
1630 description: course_description.clone(),
1631 audiences: course_audiences.clone(),
1632 ..live.clone()
1633 };
1634 let embeddings =
1635 embed_changed_metadata(conn, app_config, course_id, &edit, &course_prerequisites).await?;
1636 let course = entity_history::save_recording(
1637 conn,
1638 ContentEntityType::CourseSettings,
1639 course_id,
1640 HistoryAuthor::user(author_user_id),
1641 async move |tx| {
1642 let live = load_settings(&mut *tx, course_id).await?;
1643 let course = write_settings(
1644 &mut *tx,
1645 app_config,
1646 &edit(&live),
1647 &live,
1648 false,
1649 &embeddings,
1650 )
1651 .await?;
1652 write_prerequisites(tx, app_config, course_id, course_prerequisites, &embeddings)
1653 .await?;
1654 Ok(course)
1655 },
1656 )
1657 .await?;
1658
1659 Ok(CourseMetadata {
1660 course_description: course.description,
1661 course_audiences: crate::course_audiences::get_by_course_id(conn, course_id).await?,
1662 course_prerequisites: crate::course_prerequisites::get_by_course_id(conn, course_id)
1663 .await?,
1664 course_updated_at: course.updated_at,
1665 })
1666}
1667
1668fn audience_snapshots(audiences: Vec<EditCourseAudience>) -> Vec<CourseAudienceSnapshot> {
1670 let mut audiences: Vec<CourseAudienceSnapshot> = audiences
1671 .into_iter()
1672 .map(|a| CourseAudienceSnapshot {
1673 id: a.id,
1674 audience: a.audience,
1675 })
1676 .collect();
1677 audiences.sort_by_key(|a| a.id);
1678 audiences
1679}
1680
1681async fn write_prerequisites(
1684 conn: &mut PgConnection,
1685 app_config: &ApplicationConfiguration,
1686 course_id: Uuid,
1687 prerequisites: Vec<EditCoursePrerequisite>,
1688 embeddings: &TextEmbeddings,
1689) -> ModelResult<()> {
1690 let old_prerequisites = crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1691 let removed_ids: Vec<Uuid> = old_prerequisites
1692 .iter()
1693 .filter(|old| !prerequisites.iter().any(|p| p.id == old.id))
1694 .map(|old| old.id)
1695 .collect();
1696 let (changed_ids, changed_texts) = changed_prerequisites(&prerequisites, &old_prerequisites);
1697 if !changed_texts.is_empty() {
1698 let embeddings = embeddings.get_or_create(app_config, &changed_texts).await?;
1699 upsert_course_prerequisites(conn, course_id, changed_ids, changed_texts, embeddings)
1700 .await?;
1701 }
1702 crate::course_prerequisites::delete_batch(conn, removed_ids).await?;
1703 Ok(())
1704}
1705
1706fn changed_audiences(
1708 audiences: &[CourseAudienceSnapshot],
1709 live: &[CourseAudienceSnapshot],
1710) -> (Vec<Uuid>, Vec<String>) {
1711 changed_texts(
1712 audiences,
1713 live,
1714 |a| (a.id, a.audience.as_str()),
1715 |a| (a.id, a.audience.as_str()),
1716 )
1717}
1718
1719fn changed_prerequisites(
1721 prerequisites: &[EditCoursePrerequisite],
1722 old: &[CoursePrerequisite],
1723) -> (Vec<Uuid>, Vec<String>) {
1724 changed_texts(
1725 prerequisites,
1726 old,
1727 |p| (p.id, p.prerequisite.as_str()),
1728 |p| (p.id, p.prerequisite.as_str()),
1729 )
1730}
1731
1732fn changed_texts<New, Old>(
1734 new: &[New],
1735 old: &[Old],
1736 new_text: fn(&New) -> (Uuid, &str),
1737 old_text: fn(&Old) -> (Uuid, &str),
1738) -> (Vec<Uuid>, Vec<String>) {
1739 let old: HashMap<Uuid, &str> = old.iter().map(old_text).collect();
1740 new.iter()
1741 .map(new_text)
1742 .filter(|(id, text)| old.get(id) != Some(text))
1743 .map(|(id, text)| (id, text.to_string()))
1744 .unzip()
1745}
1746
1747async fn embed_changed_metadata(
1750 conn: &mut PgConnection,
1751 app_config: &ApplicationConfiguration,
1752 course_id: Uuid,
1753 edit: &impl Fn(&CourseSettingsSnapshot) -> CourseSettingsSnapshot,
1754 prerequisites: &[EditCoursePrerequisite],
1755) -> ModelResult<TextEmbeddings> {
1756 let live = load_settings(conn, course_id).await?;
1757 let old_prerequisites = crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1758 let mut texts = changed_settings_texts(&edit(&live), &live);
1759 texts.extend(changed_prerequisites(prerequisites, &old_prerequisites).1);
1760 TextEmbeddings::create(app_config, texts).await
1761}
1762
1763pub(crate) fn changed_settings_texts(
1765 settings: &CourseSettingsSnapshot,
1766 live: &CourseSettingsSnapshot,
1767) -> Vec<String> {
1768 let (title, description) = changed_embedded_texts(settings, live);
1769 let mut texts: Vec<String> = title
1770 .into_iter()
1771 .chain(description)
1772 .map(str::to_string)
1773 .collect();
1774 texts.extend(changed_audiences(&settings.audiences, &live.audiences).1);
1775 texts
1776}
1777
1778pub(crate) fn settings_texts(settings: &CourseSettingsSnapshot) -> Vec<String> {
1780 std::iter::once(settings.name.as_str())
1781 .chain(settings.description.as_deref().filter(|d| !d.is_empty()))
1782 .chain(settings.audiences.iter().map(|a| a.audience.as_str()))
1783 .map(str::to_string)
1784 .collect()
1785}
1786
1787fn changed_embedded_texts<'a>(
1789 settings: &'a CourseSettingsSnapshot,
1790 live: &CourseSettingsSnapshot,
1791) -> (Option<&'a str>, Option<&'a str>) {
1792 let title = (settings.name != live.name).then_some(settings.name.as_str());
1793 let description = settings
1794 .description
1795 .as_deref()
1796 .filter(|description| settings.description != live.description && !description.is_empty());
1797 (title, description)
1798}
1799
1800#[derive(Debug, Default)]
1804pub struct TextEmbeddings(HashMap<String, Vec<f32>>);
1805
1806impl TextEmbeddings {
1807 pub async fn create(
1809 app_config: &ApplicationConfiguration,
1810 texts: Vec<String>,
1811 ) -> ModelResult<Self> {
1812 Ok(Self(embed_texts(app_config, texts).await?))
1813 }
1814
1815 async fn get_or_create(
1817 &self,
1818 app_config: &ApplicationConfiguration,
1819 texts: &[String],
1820 ) -> ModelResult<Vec<Vec<f32>>> {
1821 let missing: Vec<String> = texts
1822 .iter()
1823 .filter(|text| !self.0.contains_key(*text))
1824 .cloned()
1825 .collect();
1826 let created = embed_texts(app_config, missing).await?;
1827 texts
1828 .iter()
1829 .map(|text| {
1830 self.0
1831 .get(text)
1832 .or_else(|| created.get(text))
1833 .cloned()
1834 .ok_or_else(missing_model_error(
1835 ModelErrorType::Generic,
1836 "The embedding API returned too few embeddings.",
1837 ))
1838 })
1839 .collect()
1840 }
1841
1842 async fn get_or_create_one(
1844 &self,
1845 app_config: &ApplicationConfiguration,
1846 text: Option<&str>,
1847 ) -> ModelResult<Option<Vector>> {
1848 let Some(text) = text else {
1849 return Ok(None);
1850 };
1851 let embedding = self
1852 .get_or_create(app_config, &[text.to_string()])
1853 .await?
1854 .pop()
1855 .map(Vector::from);
1856 Ok(embedding)
1857 }
1858}
1859
1860async fn embed_texts(
1861 app_config: &ApplicationConfiguration,
1862 mut texts: Vec<String>,
1863) -> ModelResult<HashMap<String, Vec<f32>>> {
1864 texts.sort();
1865 texts.dedup();
1866 if texts.is_empty() {
1867 return Ok(HashMap::new());
1868 }
1869 let embeddings = create_embeddings(app_config, texts.clone()).await?;
1870 Ok(texts.into_iter().zip(embeddings).collect())
1871}
1872
1873pub async fn get_metadata(
1874 conn: &mut PgConnection,
1875 course_id: Uuid,
1876) -> ModelResult<CompleteCourseMetadata> {
1877 let prerequisites: Vec<CoursePrerequisite> =
1878 crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1879 let audiences: Vec<CourseAudience> =
1880 crate::course_audiences::get_by_course_id(conn, course_id).await?;
1881 let course_data = get_course(conn, course_id).await?;
1882 let instances =
1883 crate::course_instances::get_course_instances_for_course(conn, course_id).await?;
1884 let module = crate::course_modules::get_default_by_course_id(conn, course_id).await?;
1885
1886 let organization =
1887 crate::organizations::get_organization(conn, course_data.organization_id).await?;
1888
1889 let metadata = CompleteCourseMetadata {
1890 course: course_data,
1891 course_instances: instances,
1892 default_module: module,
1893 course_prerequisites: prerequisites,
1894 course_audiences: audiences,
1895 course_organization: organization,
1896 };
1897 Ok(metadata)
1898}
1899
1900pub async fn update_course_embeddings(
1903 conn: &mut PgConnection,
1904 course_id: Uuid,
1905 title_embedding: Option<Vector>,
1906 description_embedding: Option<Vector>,
1907) -> ModelResult<()> {
1908 sqlx::query!(
1909 r#"
1910INSERT INTO course_embeddings (
1911 course_id,
1912 title_embedding,
1913 description_embedding
1914)
1915VALUES ($1, $2, $3)
1916ON CONFLICT (course_id) WHERE deleted_at IS NULL
1917DO UPDATE SET
1918 title_embedding = COALESCE(
1919 $2,
1920 course_embeddings.title_embedding
1921 ),
1922 description_embedding = COALESCE(
1923 $3,
1924 course_embeddings.description_embedding
1925 )
1926"#,
1927 course_id,
1928 title_embedding,
1929 description_embedding,
1930 )
1931 .execute(conn)
1932 .await?;
1933
1934 Ok(())
1935}
1936
1937pub async fn get_by_description_vectors(
1938 conn: &mut PgConnection,
1939 query_vecs: Vec<Vec<f32>>,
1940 description_keywords: Vec<String>,
1941) -> ModelResult<Vec<Uuid>> {
1942 let vectors: Vec<Vector> = query_vecs.into_iter().map(Vector::from).collect();
1943 let res = sqlx::query_scalar!(
1944 r#"
1945SELECT id
1946FROM (
1947 SELECT
1948 c.id,
1949 LEAST(MIN(ce.title_embedding <#> v.embedding),
1950 MIN(ce.description_embedding <#> v.embedding)) AS distance
1951 FROM courses c, course_embeddings ce
1952 CROSS JOIN unnest($1::vector[]) AS v(embedding)
1953 WHERE c.deleted_at IS NULL AND ce.deleted_at IS NULL AND c.id = ce.course_id
1954 GROUP BY c.id
1955 ORDER BY distance ASC
1956 LIMIT 10
1957) t
1958UNION ALL
1959SELECT DISTINCT c.id
1960FROM courses c
1961CROSS JOIN unnest($2::text[]) AS k(keyword)
1962WHERE deleted_at IS NULL
1963 AND to_tsvector(
1964 c.content_search_language::regconfig,
1965 c.name || ' ' || coalesce(c.description, '')
1966 ) @@ websearch_to_tsquery(
1967 c.content_search_language::regconfig,
1968 k.keyword
1969 )
1970 "#,
1971 &vectors as _,
1972 &description_keywords
1973 )
1974 .fetch_all(conn)
1975 .await?;
1976 Ok(res.into_iter().flatten().collect())
1977}
1978
1979#[cfg(test)]
1980mod test {
1981 use super::*;
1982 use crate::{course_language_groups, courses, test_helper::*};
1983
1984 mod language_code_validation {
1985 use super::*;
1986
1987 #[tokio::test]
1988 async fn allows_valid_language_code() {
1989 let app_config =
1990 init_app_conf().expect("Application Configuration initialization failed");
1991 insert_data!(:tx, user: _user, :org);
1992 let course_language_group_id = course_language_groups::insert(
1993 tx.as_mut(),
1994 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1995 "test-clg-allows-valid",
1996 )
1997 .await
1998 .unwrap();
1999 let new_course = create_new_course(org, "en-US");
2000 let res = courses::insert(
2001 tx.as_mut(),
2002 &app_config,
2003 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
2004 course_language_group_id,
2005 &new_course,
2006 )
2007 .await;
2008 assert!(res.is_ok());
2009 }
2010
2011 #[tokio::test]
2012 async fn disallows_empty_language_code() {
2013 let app_config =
2014 init_app_conf().expect("Application Configuration initialization failed");
2015 insert_data!(:tx, user: _user, :org);
2016 let course_language_group_id = course_language_groups::insert(
2017 tx.as_mut(),
2018 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
2019 "test-clg-disallows-empty",
2020 )
2021 .await
2022 .unwrap();
2023 let new_course = create_new_course(org, "");
2024 let res = courses::insert(
2025 tx.as_mut(),
2026 &app_config,
2027 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
2028 course_language_group_id,
2029 &new_course,
2030 )
2031 .await;
2032 assert!(res.is_err());
2033 }
2034
2035 #[tokio::test]
2036 async fn disallows_wrong_case_language_code() {
2037 let app_config =
2038 init_app_conf().expect("Application Configuration initialization failed");
2039 insert_data!(:tx, user: _user, :org);
2040 let course_language_group_id = course_language_groups::insert(
2041 tx.as_mut(),
2042 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
2043 "test-clg-disallows-wrong-case",
2044 )
2045 .await
2046 .unwrap();
2047 let new_course = create_new_course(org, "en-us");
2048 let res = courses::insert(
2049 tx.as_mut(),
2050 &app_config,
2051 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
2052 course_language_group_id,
2053 &new_course,
2054 )
2055 .await;
2056 assert!(res.is_err());
2057 }
2058
2059 #[tokio::test]
2060 async fn disallows_underscore_in_language_code() {
2061 let app_config =
2062 init_app_conf().expect("Application Configuration initialization failed");
2063 insert_data!(:tx, user: _user, :org);
2064 let course_language_group_id = course_language_groups::insert(
2065 tx.as_mut(),
2066 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
2067 "test-clg-disallows-underscore",
2068 )
2069 .await
2070 .unwrap();
2071 let new_course = create_new_course(org, "en_US");
2072 let res = courses::insert(
2073 tx.as_mut(),
2074 &app_config,
2075 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
2076 course_language_group_id,
2077 &new_course,
2078 )
2079 .await;
2080 assert!(res.is_err());
2081 }
2082
2083 fn create_new_course(organization_id: Uuid, language_code: &str) -> NewCourse {
2084 NewCourse {
2085 name: "".to_string(),
2086 slug: "".to_string(),
2087 organization_id,
2088 language_code: language_code.to_string(),
2089 teacher_in_charge_name: "teacher".to_string(),
2090 teacher_in_charge_email: "teacher@example.com".to_string(),
2091 description: "description".to_string(),
2092 is_draft: false,
2093 is_test_mode: false,
2094 is_unlisted: false,
2095 copy_user_permissions: false,
2096 is_joinable_by_code_only: false,
2097 join_code: None,
2098 ask_marketing_consent: false,
2099 flagged_answers_threshold: Some(3),
2100 can_add_chatbot: false,
2101 }
2102 }
2103 }
2104
2105 mod ai_policy {
2106 use super::*;
2107
2108 #[tokio::test]
2109 async fn update_course_round_trips_ai_policy_fields() {
2110 let app_config =
2111 init_app_conf().expect("Application Configuration initialization failed");
2112 insert_data!(:tx, :user, :org);
2113 let course_language_group_id = course_language_groups::insert(
2114 tx.as_mut(),
2115 PKeyPolicy::Fixed(Uuid::parse_str("a1b2c3d4-0000-0000-0000-000000000001").unwrap()),
2116 "test-clg-ai-policy",
2117 )
2118 .await
2119 .unwrap();
2120 let new_course = NewCourse {
2121 name: "AI policy course".to_string(),
2122 slug: "ai-policy-course".to_string(),
2123 organization_id: org,
2124 language_code: "en-US".to_string(),
2125 teacher_in_charge_name: "teacher".to_string(),
2126 teacher_in_charge_email: "teacher@example.com".to_string(),
2127 description: "description".to_string(),
2128 is_draft: false,
2129 is_test_mode: false,
2130 is_unlisted: false,
2131 copy_user_permissions: false,
2132 is_joinable_by_code_only: false,
2133 join_code: None,
2134 ask_marketing_consent: false,
2135 flagged_answers_threshold: Some(3),
2136 can_add_chatbot: false,
2137 };
2138 let course_id = courses::insert(
2139 tx.as_mut(),
2140 &app_config,
2141 PKeyPolicy::Fixed(Uuid::parse_str("a1b2c3d4-0000-0000-0000-000000000002").unwrap()),
2142 course_language_group_id,
2143 &new_course,
2144 )
2145 .await
2146 .unwrap();
2147
2148 let created = courses::get_course(tx.as_mut(), course_id).await.unwrap();
2150 assert_eq!(created.ai_policy, CourseAiPolicy::NotSet);
2151 assert_eq!(created.course_material_ai_instructions, None);
2152
2153 let updated = courses::update_course(
2155 tx.as_mut(),
2156 &app_config,
2157 course_id,
2158 CourseUpdate {
2159 name: created.name.clone(),
2160 flagged_answers_threshold: 3,
2161 ai_policy: CourseAiPolicy::Limited,
2162 course_material_ai_instructions: Some(true),
2163 ..Default::default()
2164 },
2165 user,
2166 false,
2167 )
2168 .await
2169 .unwrap();
2170 assert_eq!(updated.ai_policy, CourseAiPolicy::Limited);
2171 assert_eq!(updated.course_material_ai_instructions, Some(true));
2172
2173 let reread = courses::get_course(tx.as_mut(), course_id).await.unwrap();
2175 assert_eq!(reread.ai_policy, CourseAiPolicy::Limited);
2176 assert_eq!(reread.course_material_ai_instructions, Some(true));
2177 }
2178 }
2179
2180 mod enrolled_courses_with_exercise_types {
2181 use super::*;
2182 use crate::{
2183 course_instance_enrollments::{self, NewCourseInstanceEnrollment},
2184 exercise_tasks::{self, NewExerciseTask},
2185 };
2186
2187 #[tokio::test]
2188 async fn gets_enrolled_courses_with_exercise_types() {
2189 insert_data!(:tx, user:user_id, :org, course:course_id, :instance, course_module:_course_module_id, chapter:chapter_id, page:page_id, :exercise, slide:exercise_slide_id);
2190
2191 course_instance_enrollments::insert_enrollment_and_set_as_current(
2192 tx.as_mut(),
2193 NewCourseInstanceEnrollment {
2194 course_id,
2195 user_id,
2196 course_instance_id: instance.id,
2197 },
2198 )
2199 .await
2200 .unwrap();
2201 let tmc = ["tmc".to_string()];
2202 let courses = get_enrolled_courses_with_exercise_types(tx.as_mut(), user_id, &tmc)
2203 .await
2204 .unwrap();
2205 assert!(
2206 courses.is_empty(),
2207 "user should not be enrolled on any course with tmc exercises"
2208 );
2209
2210 exercise_tasks::insert(
2211 tx.as_mut(),
2212 PKeyPolicy::Generate,
2213 NewExerciseTask {
2214 assignment: Vec::new(),
2215 exercise_slide_id,
2216 exercise_type: "tmc".to_string(),
2217 model_solution_spec: None,
2218 private_spec: None,
2219 public_spec: None,
2220 order_number: 1,
2221 },
2222 )
2223 .await
2224 .unwrap();
2225 let courses = get_enrolled_courses_with_exercise_types(tx.as_mut(), user_id, &tmc)
2226 .await
2227 .unwrap();
2228 assert_eq!(
2229 courses.len(),
2230 1,
2231 "user should be enrolled on one course with tmc exercises"
2232 );
2233
2234 let others = ["quizzes".to_string(), "example-exercise".to_string()];
2236 assert!(
2237 get_enrolled_courses_with_exercise_types(tx.as_mut(), user_id, &others)
2238 .await
2239 .unwrap()
2240 .is_empty()
2241 );
2242 assert!(
2243 get_enrolled_courses_with_exercise_types(tx.as_mut(), user_id, &[])
2244 .await
2245 .unwrap()
2246 .is_empty()
2247 );
2248
2249 let mixed = ["quizzes".to_string(), "tmc".to_string()];
2251 assert_eq!(
2252 get_enrolled_courses_with_exercise_types(tx.as_mut(), user_id, &mixed)
2253 .await
2254 .unwrap()
2255 .len(),
2256 1
2257 );
2258 tx.rollback().await;
2259 }
2260
2261 #[tokio::test]
2262 async fn lists_a_course_once_when_enrolled_on_several_of_its_instances() {
2263 insert_data!(:tx, user:user_id, :org, course:course_id, :instance, course_module:_course_module_id, chapter:_chapter_id, page:_page_id, :exercise, slide:exercise_slide_id);
2264 exercise_tasks::insert(
2265 tx.as_mut(),
2266 PKeyPolicy::Generate,
2267 NewExerciseTask {
2268 assignment: Vec::new(),
2269 exercise_slide_id,
2270 exercise_type: "tmc".to_string(),
2271 model_solution_spec: None,
2272 private_spec: None,
2273 public_spec: None,
2274 order_number: 1,
2275 },
2276 )
2277 .await
2278 .unwrap();
2279 let default_instance =
2280 crate::course_instances::get_default_by_course_id(tx.as_mut(), course_id)
2281 .await
2282 .unwrap();
2283 for course_instance_id in [instance.id, default_instance.id] {
2284 course_instance_enrollments::insert_enrollment_and_set_as_current(
2285 tx.as_mut(),
2286 NewCourseInstanceEnrollment {
2287 course_id,
2288 user_id,
2289 course_instance_id,
2290 },
2291 )
2292 .await
2293 .unwrap();
2294 }
2295
2296 let courses = get_enrolled_courses_with_exercise_types(
2297 tx.as_mut(),
2298 user_id,
2299 &["tmc".to_string()],
2300 )
2301 .await
2302 .unwrap();
2303 assert_eq!(
2304 courses.iter().map(|c| c.id).collect::<Vec<_>>(),
2305 vec![course_id]
2306 );
2307 tx.rollback().await;
2308 }
2309 }
2310}