1use crate::{
2 chapters::{Chapter, get_course_chapters},
3 course_audiences::{CourseAudience, NewCourseAudience},
4 course_instances::CourseInstance,
5 course_modules::CourseModule,
6 course_prerequisites::{
7 CoursePrerequisite, NewCoursePrerequisite, insert_course_prerequisites,
8 },
9 organizations::DatabaseOrganization,
10 pages::{Page, PageVisibility, get_all_by_course_id_and_visibility},
11 prelude::*,
12};
13use headless_lms_utils::{file_store::FileStore, language_tag_to_name::LANGUAGE_TAG_TO_NAME};
14use utoipa::ToSchema;
15
16pub struct CourseInfo {
17 pub id: Uuid,
18 pub is_draft: bool,
19}
20
21pub struct CourseDescription {
22 pub id: Uuid,
23 pub description: Option<String>,
24}
25
26#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
27
28pub struct CourseCount {
29 pub count: u32,
30}
31
32pub struct CourseContextData {
33 pub id: Uuid,
34 pub is_test_mode: bool,
35}
36
37#[derive(
40 Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default, sqlx::Type, ToSchema,
41)]
42#[sqlx(type_name = "course_ai_policy", rename_all = "snake_case")]
43pub enum CourseAiPolicy {
44 #[default]
46 NotSet,
47 NoAi,
49 PlanningOnly,
51 Limited,
53 FullUse,
55 Required,
57}
58
59#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
60
61pub struct Course {
62 pub id: Uuid,
63 pub slug: String,
64 pub created_at: DateTime<Utc>,
65 pub updated_at: DateTime<Utc>,
66 pub name: String,
67 pub description: Option<String>,
68 pub organization_id: Uuid,
69 pub deleted_at: Option<DateTime<Utc>>,
70 pub language_code: String,
71 pub copied_from: Option<Uuid>,
72 pub content_search_language: Option<String>,
73 pub course_language_group_id: Uuid,
74 pub is_draft: bool,
75 pub is_test_mode: bool,
76 pub is_unlisted: bool,
77 pub base_module_completion_requires_n_submodule_completions: i32,
78 pub can_add_chatbot: bool,
79 pub is_joinable_by_code_only: bool,
80 pub join_code: Option<String>,
81 pub ask_marketing_consent: bool,
82 pub flagged_answers_threshold: Option<i32>,
83 pub flagged_answers_skip_manual_review_and_allow_retry: bool,
84 pub closed_at: Option<DateTime<Utc>>,
85 pub closed_additional_message: Option<String>,
86 pub closed_course_successor_id: Option<Uuid>,
87 pub chapter_locking_enabled: bool,
88 pub cheater_detection_enabled: bool,
89 pub ai_policy: CourseAiPolicy,
90 pub course_material_ai_instructions: Option<bool>,
91}
92
93#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
95
96pub struct CourseMaterialCourse {
97 pub id: Uuid,
98 pub slug: String,
99 pub name: String,
100 pub description: Option<String>,
101 pub organization_id: Uuid,
102 pub language_code: String,
103 pub copied_from: Option<Uuid>,
104 pub content_search_language: Option<String>,
105 pub course_language_group_id: Uuid,
106 pub is_draft: bool,
107 pub is_test_mode: bool,
108 pub is_unlisted: bool,
109 pub base_module_completion_requires_n_submodule_completions: i32,
110 pub is_joinable_by_code_only: bool,
111 pub ask_marketing_consent: bool,
112 pub closed_at: Option<DateTime<Utc>>,
113 pub closed_additional_message: Option<String>,
114 pub closed_course_successor_id: Option<Uuid>,
115 pub chapter_locking_enabled: bool,
116 pub ai_policy: CourseAiPolicy,
117 pub course_material_ai_instructions: Option<bool>,
118}
119
120impl From<Course> for CourseMaterialCourse {
121 fn from(course: Course) -> Self {
122 CourseMaterialCourse {
123 id: course.id,
124 slug: course.slug,
125 name: course.name,
126 description: course.description,
127 organization_id: course.organization_id,
128 language_code: course.language_code,
129 copied_from: course.copied_from,
130 content_search_language: course.content_search_language,
131 course_language_group_id: course.course_language_group_id,
132 is_draft: course.is_draft,
133 is_test_mode: course.is_test_mode,
134 is_unlisted: course.is_unlisted,
135 base_module_completion_requires_n_submodule_completions: course
136 .base_module_completion_requires_n_submodule_completions,
137 is_joinable_by_code_only: course.is_joinable_by_code_only,
138 ask_marketing_consent: course.ask_marketing_consent,
139 closed_at: course.closed_at,
140 closed_additional_message: course.closed_additional_message,
141 closed_course_successor_id: course.closed_course_successor_id,
142 chapter_locking_enabled: course.chapter_locking_enabled,
143 ai_policy: course.ai_policy,
144 course_material_ai_instructions: course.course_material_ai_instructions,
145 }
146 }
147}
148
149#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
151
152pub struct CourseLanguageVersionNavigationInfo {
153 pub course_language_group_id: Uuid,
154 pub course_id: Uuid,
155 pub language_code: String,
156 pub course_slug: String,
157 pub page_path: String,
158 pub is_draft: bool,
159 pub current_page_unavailable_in_this_language: bool,
160}
161
162impl CourseLanguageVersionNavigationInfo {
163 pub fn from_course_and_page_info(
165 course: &Course,
166 page_info: Option<&crate::page_language_groups::PageLanguageGroupNavigationInfo>,
167 ) -> Self {
168 Self {
169 course_language_group_id: course.course_language_group_id,
170 course_id: course.id,
171 language_code: course.language_code.clone(),
172 course_slug: course.slug.clone(),
173 page_path: page_info
174 .map(|p| p.page_path.clone())
175 .unwrap_or_else(|| "/".to_string()),
176 is_draft: course.is_draft,
177 current_page_unavailable_in_this_language: page_info.is_none(),
178 }
179 }
180}
181
182#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
183
184pub struct CourseBreadcrumbInfo {
185 pub course_id: Uuid,
186 pub course_name: String,
187 pub course_slug: String,
188 pub organization_slug: String,
189 pub organization_name: String,
190}
191
192#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
194
195pub struct NewCourse {
196 pub name: String,
197 pub slug: String,
198 pub organization_id: Uuid,
199 pub language_code: String,
200 pub teacher_in_charge_name: String,
202 pub teacher_in_charge_email: String,
204 pub description: String,
205 pub is_draft: bool,
206 pub is_test_mode: bool,
207 pub is_unlisted: bool,
208 pub copy_user_permissions: bool,
210 pub is_joinable_by_code_only: bool,
211 pub join_code: Option<String>,
212 pub ask_marketing_consent: bool,
213 pub flagged_answers_threshold: Option<i32>,
214 pub can_add_chatbot: bool,
215}
216
217pub async fn insert(
218 conn: &mut PgConnection,
219 pkey_policy: PKeyPolicy<Uuid>,
220 course_language_group_id: Uuid,
221 new_course: &NewCourse,
222) -> ModelResult<Uuid> {
223 let res = sqlx::query!(
224 "
225INSERT INTO courses(
226 id,
227 name,
228 description,
229 slug,
230 organization_id,
231 language_code,
232 course_language_group_id,
233 is_draft,
234 is_test_mode,
235 is_joinable_by_code_only,
236 join_code,
237 can_add_chatbot
238 )
239VALUES(
240 $1,
241 $2,
242 $3,
243 $4,
244 $5,
245 $6,
246 $7,
247 $8,
248 $9,
249 $10,
250 $11,
251 $12
252 )
253RETURNING id
254 ",
255 pkey_policy.into_uuid(),
256 new_course.name,
257 new_course.description,
258 new_course.slug,
259 new_course.organization_id,
260 new_course.language_code,
261 course_language_group_id,
262 new_course.is_draft,
263 new_course.is_test_mode,
264 new_course.is_joinable_by_code_only,
265 new_course.join_code,
266 new_course.can_add_chatbot,
267 )
268 .fetch_one(conn)
269 .await?;
270 Ok(res.id)
271}
272
273#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
274
275pub struct CourseStructure {
276 pub course: Course,
277 pub pages: Vec<Page>,
278 pub chapters: Vec<Chapter>,
279 pub modules: Vec<CourseModule>,
280}
281
282pub async fn all_courses(conn: &mut PgConnection) -> ModelResult<Vec<Course>> {
283 let courses = sqlx::query_as!(
284 Course,
285 r#"
286SELECT id,
287 name,
288 created_at,
289 updated_at,
290 organization_id,
291 deleted_at,
292 slug,
293 content_search_language::text,
294 language_code,
295 copied_from,
296 course_language_group_id,
297 description,
298 is_draft,
299 is_test_mode,
300 base_module_completion_requires_n_submodule_completions,
301 can_add_chatbot,
302 is_unlisted,
303 is_joinable_by_code_only,
304 join_code,
305 ask_marketing_consent,
306 flagged_answers_threshold,
307 flagged_answers_skip_manual_review_and_allow_retry,
308 closed_at,
309 closed_additional_message,
310 closed_course_successor_id,
311 chapter_locking_enabled,
312 cheater_detection_enabled,
313 ai_policy,
314 course_material_ai_instructions
315FROM courses
316WHERE deleted_at IS NULL;
317"#
318 )
319 .fetch_all(conn)
320 .await?;
321 Ok(courses)
322}
323
324pub async fn all_courses_user_enrolled_to(
325 conn: &mut PgConnection,
326 user_id: Uuid,
327) -> ModelResult<Vec<Course>> {
328 let courses = sqlx::query_as!(
329 Course,
330 r#"
331SELECT id,
332 name,
333 created_at,
334 updated_at,
335 organization_id,
336 deleted_at,
337 slug,
338 content_search_language::text,
339 language_code,
340 copied_from,
341 course_language_group_id,
342 description,
343 is_draft,
344 is_test_mode,
345 is_unlisted,
346 base_module_completion_requires_n_submodule_completions,
347 can_add_chatbot,
348 is_joinable_by_code_only,
349 join_code,
350 ask_marketing_consent,
351 flagged_answers_threshold,
352 flagged_answers_skip_manual_review_and_allow_retry,
353 closed_at,
354 closed_additional_message,
355 closed_course_successor_id,
356 chapter_locking_enabled,
357 cheater_detection_enabled,
358 ai_policy,
359 course_material_ai_instructions
360FROM courses
361WHERE courses.deleted_at IS NULL
362 AND id IN (
363 SELECT current_course_id
364 FROM user_course_settings
365 WHERE deleted_at IS NULL
366 AND user_id = $1
367 )
368"#,
369 user_id
370 )
371 .fetch_all(conn)
372 .await?;
373 Ok(courses)
374}
375
376pub async fn all_courses_with_roles_for_user(
377 conn: &mut PgConnection,
378 user_id: Uuid,
379) -> ModelResult<Vec<Course>> {
380 let courses = sqlx::query_as!(
381 Course,
382 r#"
383SELECT id,
384 name,
385 created_at,
386 updated_at,
387 organization_id,
388 deleted_at,
389 slug,
390 content_search_language::text,
391 language_code,
392 copied_from,
393 course_language_group_id,
394 description,
395 is_draft,
396 is_test_mode,
397 can_add_chatbot,
398 is_unlisted,
399 base_module_completion_requires_n_submodule_completions,
400 is_joinable_by_code_only,
401 join_code,
402 ask_marketing_consent,
403 flagged_answers_threshold,
404 flagged_answers_skip_manual_review_and_allow_retry,
405 closed_at,
406 closed_additional_message,
407 closed_course_successor_id,
408 chapter_locking_enabled,
409 cheater_detection_enabled,
410 ai_policy,
411 course_material_ai_instructions
412FROM courses
413WHERE courses.deleted_at IS NULL
414 AND (
415 id IN (
416 SELECT course_id
417 FROM roles
418 WHERE deleted_at IS NULL
419 AND user_id = $1
420 AND course_id IS NOT NULL
421 )
422 OR (
423 id IN (
424 SELECT ci.course_id
425 FROM course_instances ci
426 JOIN ROLES r ON r.course_instance_id = ci.id
427 WHERE r.user_id = $1
428 AND r.deleted_at IS NULL
429 AND ci.deleted_at IS NULL
430 )
431 )
432 ) "#,
433 user_id
434 )
435 .fetch_all(conn)
436 .await?;
437 Ok(courses)
438}
439
440pub async fn get_all_language_versions_of_course(
441 conn: &mut PgConnection,
442 course: &Course,
443) -> ModelResult<Vec<Course>> {
444 let courses = sqlx::query_as!(
445 Course,
446 r#"
447SELECT id,
448 name,
449 created_at,
450 updated_at,
451 organization_id,
452 deleted_at,
453 slug,
454 content_search_language::text,
455 language_code,
456 copied_from,
457 course_language_group_id,
458 description,
459 is_draft,
460 is_test_mode,
461 base_module_completion_requires_n_submodule_completions,
462 can_add_chatbot,
463 is_unlisted,
464 is_joinable_by_code_only,
465 join_code,
466 ask_marketing_consent,
467 flagged_answers_threshold,
468 flagged_answers_skip_manual_review_and_allow_retry,
469 closed_at,
470 closed_additional_message,
471 closed_course_successor_id,
472 chapter_locking_enabled,
473 cheater_detection_enabled,
474 ai_policy,
475 course_material_ai_instructions
476FROM courses
477WHERE course_language_group_id = $1
478AND deleted_at IS NULL
479 "#,
480 course.course_language_group_id,
481 )
482 .fetch_all(conn)
483 .await?;
484 Ok(courses)
485}
486
487pub async fn get_active_courses_for_organization(
488 conn: &mut PgConnection,
489 organization_id: Uuid,
490 pagination: Pagination,
491) -> ModelResult<Vec<Course>> {
492 let course_instances = sqlx::query_as!(
493 Course,
494 r#"
495SELECT
496 DISTINCT(c.id),
497 c.name,
498 c.created_at,
499 c.updated_at,
500 c.organization_id,
501 c.deleted_at,
502 c.slug,
503 c.content_search_language::text,
504 c.language_code,
505 c.copied_from,
506 c.course_language_group_id,
507 c.description,
508 c.is_draft,
509 c.is_test_mode,
510 c.base_module_completion_requires_n_submodule_completions,
511 c.can_add_chatbot,
512 c.is_unlisted,
513 c.is_joinable_by_code_only,
514 c.join_code,
515 c.ask_marketing_consent,
516 c.flagged_answers_threshold,
517 c.flagged_answers_skip_manual_review_and_allow_retry,
518 c.closed_at,
519 c.closed_additional_message,
520 c.closed_course_successor_id,
521 c.chapter_locking_enabled,
522 c.cheater_detection_enabled,
523 c.ai_policy,
524 c.course_material_ai_instructions
525FROM courses as c
526 LEFT JOIN course_instances as ci on c.id = ci.course_id
527WHERE
528 c.organization_id = $1 AND
529 ci.starts_at < NOW() AND ci.ends_at > NOW() AND
530 c.deleted_at IS NULL AND ci.deleted_at IS NULL
531 LIMIT $2 OFFSET $3;
532 "#,
533 organization_id,
534 pagination.limit(),
535 pagination.offset()
536 )
537 .fetch_all(conn)
538 .await?;
539 Ok(course_instances)
540}
541
542pub async fn get_active_courses_for_organization_count(
543 conn: &mut PgConnection,
544 organization_id: Uuid,
545) -> ModelResult<CourseCount> {
546 let result = sqlx::query!(
547 r#"
548SELECT
549 COUNT(DISTINCT c.id) as count
550FROM courses as c
551 LEFT JOIN course_instances as ci on c.id = ci.course_id
552WHERE
553 c.organization_id = $1 AND
554 ci.starts_at < NOW() AND ci.ends_at > NOW() AND
555 c.deleted_at IS NULL AND ci.deleted_at IS NULL;
556 "#,
557 organization_id
558 )
559 .fetch_one(conn)
560 .await?;
561 Ok(CourseCount {
562 count: result.count.unwrap_or_default().try_into()?,
563 })
564}
565
566pub async fn get_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
567 let course = sqlx::query_as!(
568 Course,
569 r#"
570SELECT id,
571 name,
572 created_at,
573 updated_at,
574 organization_id,
575 deleted_at,
576 slug,
577 content_search_language::text,
578 language_code,
579 copied_from,
580 course_language_group_id,
581 description,
582 is_draft,
583 is_test_mode,
584 can_add_chatbot,
585 is_unlisted,
586 base_module_completion_requires_n_submodule_completions,
587 is_joinable_by_code_only,
588 join_code,
589 ask_marketing_consent,
590 flagged_answers_threshold,
591 flagged_answers_skip_manual_review_and_allow_retry,
592 closed_at,
593 closed_additional_message,
594 closed_course_successor_id,
595 chapter_locking_enabled,
596 cheater_detection_enabled,
597 ai_policy,
598 course_material_ai_instructions
599FROM courses
600WHERE id = $1
601 AND deleted_at IS NULL;
602 "#,
603 course_id
604 )
605 .fetch_one(conn)
606 .await?;
607 Ok(course)
608}
609
610pub async fn get_by_id_and_join_code(
611 conn: &mut PgConnection,
612 course_id: Uuid,
613 join_code: &str,
614) -> ModelResult<Course> {
615 let course = sqlx::query_as!(
616 Course,
617 r#"
618SELECT id,
619 name,
620 created_at,
621 updated_at,
622 organization_id,
623 deleted_at,
624 slug,
625 content_search_language::text,
626 language_code,
627 copied_from,
628 course_language_group_id,
629 description,
630 is_draft,
631 is_test_mode,
632 can_add_chatbot,
633 is_unlisted,
634 base_module_completion_requires_n_submodule_completions,
635 is_joinable_by_code_only,
636 join_code,
637 ask_marketing_consent,
638 flagged_answers_threshold,
639 flagged_answers_skip_manual_review_and_allow_retry,
640 closed_at,
641 closed_additional_message,
642 closed_course_successor_id,
643 chapter_locking_enabled,
644 cheater_detection_enabled,
645 ai_policy,
646 course_material_ai_instructions
647FROM courses
648WHERE id = $1
649 AND join_code = $2
650 AND deleted_at IS NULL;
651 "#,
652 course_id,
653 join_code,
654 )
655 .fetch_one(conn)
656 .await?;
657 Ok(course)
658}
659
660pub async fn get_course_breadcrumb_info(
661 conn: &mut PgConnection,
662 course_id: Uuid,
663) -> ModelResult<CourseBreadcrumbInfo> {
664 let res = sqlx::query_as!(
665 CourseBreadcrumbInfo,
666 r#"
667SELECT courses.id as course_id,
668 courses.name as course_name,
669 courses.slug as course_slug,
670 organizations.slug as organization_slug,
671 organizations.name as organization_name
672FROM courses
673 JOIN organizations ON (courses.organization_id = organizations.id)
674WHERE courses.id = $1
675 AND courses.deleted_at IS NULL;
676 "#,
677 course_id
678 )
679 .fetch_one(conn)
680 .await?;
681 Ok(res)
682}
683
684pub async fn get_nondeleted_course_id_by_slug(
685 conn: &mut PgConnection,
686 slug: &str,
687) -> ModelResult<CourseContextData> {
688 let data = sqlx::query_as!(
689 CourseContextData,
690 "SELECT id, is_test_mode FROM courses WHERE slug = $1 AND deleted_at IS NULL",
691 slug
692 )
693 .fetch_one(conn)
694 .await?;
695 Ok(data)
696}
697
698pub async fn get_organization_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
699 let organization_id = sqlx::query!("SELECT organization_id FROM courses WHERE id = $1", id)
700 .fetch_one(conn)
701 .await?
702 .organization_id;
703 Ok(organization_id)
704}
705
706pub async fn get_course_structure(
708 conn: &mut PgConnection,
709 course_id: Uuid,
710 file_store: &dyn FileStore,
711 app_conf: &ApplicationConfiguration,
712) -> ModelResult<CourseStructure> {
713 let course = get_course(conn, course_id).await?;
714 let pages = get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
715 let chapters = get_course_chapters(conn, course_id)
716 .await?
717 .iter()
718 .map(|chapter| Chapter::from_database_chapter(chapter, file_store, app_conf))
719 .collect();
720 let modules = crate::course_modules::get_by_course_id(conn, course_id).await?;
721 Ok(CourseStructure {
722 course,
723 pages,
724 chapters,
725 modules,
726 })
727}
728
729pub async fn organization_courses_visible_to_user_paginated(
730 conn: &mut PgConnection,
731 organization_id: Uuid,
732 user: Option<Uuid>,
733 pagination: Pagination,
734) -> ModelResult<Vec<Course>> {
735 let courses = sqlx::query_as!(
736 Course,
737 r#"
738SELECT courses.id,
739 courses.name,
740 courses.created_at,
741 courses.updated_at,
742 courses.organization_id,
743 courses.deleted_at,
744 courses.slug,
745 courses.content_search_language::text,
746 courses.language_code,
747 courses.copied_from,
748 courses.course_language_group_id,
749 courses.description,
750 courses.is_draft,
751 courses.is_test_mode,
752 base_module_completion_requires_n_submodule_completions,
753 can_add_chatbot,
754 courses.is_unlisted,
755 courses.is_joinable_by_code_only,
756 courses.join_code,
757 courses.ask_marketing_consent,
758 courses.flagged_answers_threshold,
759 courses.flagged_answers_skip_manual_review_and_allow_retry,
760 courses.closed_at,
761 courses.closed_additional_message,
762 courses.closed_course_successor_id,
763 courses.chapter_locking_enabled,
764 courses.cheater_detection_enabled,
765 courses.ai_policy,
766 courses.course_material_ai_instructions
767FROM courses
768WHERE courses.organization_id = $1
769 AND (
770 (
771 courses.is_draft IS FALSE
772 AND courses.is_unlisted IS FALSE
773 )
774 OR EXISTS (
775 SELECT id
776 FROM roles
777 WHERE user_id = $2
778 AND (
779 course_id = courses.id
780 OR roles.organization_id = courses.organization_id
781 OR roles.is_global IS TRUE
782 )
783 )
784 )
785 AND courses.deleted_at IS NULL
786ORDER BY courses.name
787LIMIT $3 OFFSET $4;
788"#,
789 organization_id,
790 user,
791 pagination.limit(),
792 pagination.offset()
793 )
794 .fetch_all(conn)
795 .await?;
796 Ok(courses)
797}
798
799pub async fn organization_course_count(
800 conn: &mut PgConnection,
801 organization_id: Uuid,
802) -> ModelResult<CourseCount> {
803 let course_count = sqlx::query!(
804 r#"
805SELECT
806 COUNT(DISTINCT id) as count
807FROM courses
808WHERE organization_id = $1
809 AND deleted_at IS NULL;
810 "#,
811 organization_id,
812 )
813 .fetch_one(conn)
814 .await?;
815 Ok(CourseCount {
816 count: course_count.count.unwrap_or_default().try_into()?,
817 })
818}
819#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default, ToSchema)]
821
822pub struct CourseUpdate {
823 pub name: String,
824 pub description: Option<String>,
825 pub is_draft: bool,
826 pub is_test_mode: bool,
827 pub can_add_chatbot: bool,
828 pub is_unlisted: bool,
829 pub is_joinable_by_code_only: bool,
830 pub ask_marketing_consent: bool,
831 pub flagged_answers_threshold: i32,
832 pub flagged_answers_skip_manual_review_and_allow_retry: bool,
833 pub closed_at: Option<DateTime<Utc>>,
834 pub closed_additional_message: Option<String>,
835 pub closed_course_successor_id: Option<Uuid>,
836 pub chapter_locking_enabled: bool,
837 pub ai_policy: CourseAiPolicy,
838 pub course_material_ai_instructions: Option<bool>,
839}
840
841pub async fn update_course(
842 conn: &mut PgConnection,
843 course_id: Uuid,
844 course_update: CourseUpdate,
845) -> ModelResult<Course> {
846 let res = sqlx::query_as!(
847 Course,
848 r#"
849UPDATE courses
850SET name = $1,
851 description = $2,
852 is_draft = $3,
853 is_test_mode = $4,
854 can_add_chatbot = $5,
855 is_unlisted = $6,
856 is_joinable_by_code_only = $7,
857 ask_marketing_consent = $8,
858 flagged_answers_threshold = $9,
859 flagged_answers_skip_manual_review_and_allow_retry = $10,
860 closed_at = $11,
861 closed_additional_message = $12,
862 closed_course_successor_id = $13,
863 chapter_locking_enabled = $14,
864 ai_policy = $15,
865 course_material_ai_instructions = $16
866WHERE id = $17
867 AND deleted_at IS NULL
868RETURNING id,
869 name,
870 created_at,
871 updated_at,
872 organization_id,
873 deleted_at,
874 slug,
875 content_search_language::text,
876 language_code,
877 copied_from,
878 course_language_group_id,
879 description,
880 is_draft,
881 is_test_mode,
882 can_add_chatbot,
883 is_unlisted,
884 base_module_completion_requires_n_submodule_completions,
885 is_joinable_by_code_only,
886 join_code,
887 ask_marketing_consent,
888 flagged_answers_threshold,
889 flagged_answers_skip_manual_review_and_allow_retry,
890 closed_at,
891 closed_additional_message,
892 closed_course_successor_id,
893 chapter_locking_enabled,
894 cheater_detection_enabled,
895 ai_policy,
896 course_material_ai_instructions
897 "#,
898 course_update.name,
899 course_update.description,
900 course_update.is_draft,
901 course_update.is_test_mode,
902 course_update.can_add_chatbot,
903 course_update.is_unlisted,
904 course_update.is_joinable_by_code_only,
905 course_update.ask_marketing_consent,
906 course_update.flagged_answers_threshold,
907 course_update.flagged_answers_skip_manual_review_and_allow_retry,
908 course_update.closed_at,
909 course_update.closed_additional_message,
910 course_update.closed_course_successor_id,
911 course_update.chapter_locking_enabled,
912 course_update.ai_policy as CourseAiPolicy,
913 course_update.course_material_ai_instructions,
914 course_id
915 )
916 .fetch_one(conn)
917 .await?;
918 Ok(res)
919}
920
921pub async fn set_cheater_detection_enabled(
925 conn: &mut PgConnection,
926 course_id: Uuid,
927 enabled: bool,
928) -> ModelResult<()> {
929 sqlx::query!(
930 "
931UPDATE courses
932SET cheater_detection_enabled = $1
933WHERE id = $2
934 AND deleted_at IS NULL
935 ",
936 enabled,
937 course_id,
938 )
939 .execute(conn)
940 .await?;
941 Ok(())
942}
943
944pub async fn update_course_base_module_completion_count_requirement(
945 conn: &mut PgConnection,
946 id: Uuid,
947 base_module_completion_requires_n_submodule_completions: i32,
948) -> ModelResult<bool> {
949 let res = sqlx::query!(
950 "
951UPDATE courses
952SET base_module_completion_requires_n_submodule_completions = $1
953WHERE id = $2
954 AND deleted_at IS NULL
955 ",
956 base_module_completion_requires_n_submodule_completions,
957 id,
958 )
959 .execute(conn)
960 .await?;
961 Ok(res.rows_affected() > 0)
962}
963
964pub async fn delete_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
965 let deleted = sqlx::query_as!(
966 Course,
967 r#"
968UPDATE courses
969SET deleted_at = now()
970WHERE id = $1
971AND deleted_at IS NULL
972RETURNING id,
973 name,
974 created_at,
975 updated_at,
976 organization_id,
977 deleted_at,
978 slug,
979 content_search_language::text,
980 language_code,
981 copied_from,
982 course_language_group_id,
983 description,
984 is_draft,
985 is_test_mode,
986 can_add_chatbot,
987 is_unlisted,
988 base_module_completion_requires_n_submodule_completions,
989 is_joinable_by_code_only,
990 join_code,
991 ask_marketing_consent,
992 flagged_answers_threshold,
993 flagged_answers_skip_manual_review_and_allow_retry,
994 closed_at,
995 closed_additional_message,
996 closed_course_successor_id,
997 chapter_locking_enabled,
998 cheater_detection_enabled,
999 ai_policy,
1000 course_material_ai_instructions
1001 "#,
1002 course_id
1003 )
1004 .fetch_one(conn)
1005 .await?;
1006 Ok(deleted)
1007}
1008
1009pub async fn get_course_by_slug(conn: &mut PgConnection, course_slug: &str) -> ModelResult<Course> {
1010 let course = sqlx::query_as!(
1011 Course,
1012 r#"
1013SELECT id,
1014 name,
1015 created_at,
1016 updated_at,
1017 organization_id,
1018 deleted_at,
1019 slug,
1020 content_search_language::text,
1021 language_code,
1022 copied_from,
1023 course_language_group_id,
1024 description,
1025 is_draft,
1026 is_test_mode,
1027 can_add_chatbot,
1028 is_unlisted,
1029 base_module_completion_requires_n_submodule_completions,
1030 is_joinable_by_code_only,
1031 join_code,
1032 ask_marketing_consent,
1033 flagged_answers_threshold,
1034 flagged_answers_skip_manual_review_and_allow_retry,
1035 closed_at,
1036 closed_additional_message,
1037 closed_course_successor_id,
1038 chapter_locking_enabled,
1039 cheater_detection_enabled,
1040 ai_policy,
1041 course_material_ai_instructions
1042FROM courses
1043WHERE slug = $1
1044 AND deleted_at IS NULL
1045"#,
1046 course_slug,
1047 )
1048 .fetch_one(conn)
1049 .await?;
1050 Ok(course)
1051}
1052
1053pub async fn get_cfgname_by_tag(
1054 conn: &mut PgConnection,
1055 ietf_language_tag: String,
1056) -> ModelResult<String> {
1057 let tag = ietf_language_tag
1058 .split('-')
1059 .next()
1060 .unwrap_or_else(|| &ietf_language_tag[..]);
1061
1062 let lang_name = LANGUAGE_TAG_TO_NAME.get(&tag);
1063
1064 let name = sqlx::query!(
1065 "SELECT cfgname::text FROM pg_ts_config WHERE cfgname = $1",
1066 lang_name
1067 )
1068 .fetch_optional(conn)
1069 .await?;
1070
1071 let res = name
1072 .and_then(|n| n.cfgname)
1073 .unwrap_or_else(|| "simple".to_string());
1074
1075 Ok(res)
1076}
1077
1078pub async fn is_draft(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
1079 let res = sqlx::query!(
1080 "
1081SELECT is_draft
1082FROM courses
1083WHERE id = $1
1084",
1085 id
1086 )
1087 .fetch_one(conn)
1088 .await?;
1089 Ok(res.is_draft)
1090}
1091
1092pub async fn is_joinable_by_code_only(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
1093 let res = sqlx::query!(
1094 "
1095SELECT is_joinable_by_code_only
1096FROM courses
1097WHERE id = $1
1098",
1099 id
1100 )
1101 .fetch_one(conn)
1102 .await?;
1103 Ok(res.is_joinable_by_code_only)
1104}
1105
1106pub(crate) async fn get_by_ids(
1107 conn: &mut PgConnection,
1108 course_ids: &[Uuid],
1109) -> ModelResult<Vec<Course>> {
1110 let courses = sqlx::query_as!(
1111 Course,
1112 r#"
1113SELECT id,
1114 name,
1115 created_at,
1116 updated_at,
1117 organization_id,
1118 deleted_at,
1119 slug,
1120 content_search_language::text,
1121 language_code,
1122 copied_from,
1123 course_language_group_id,
1124 description,
1125 is_draft,
1126 is_test_mode,
1127 can_add_chatbot,
1128 is_unlisted,
1129 base_module_completion_requires_n_submodule_completions,
1130 is_joinable_by_code_only,
1131 join_code,
1132 ask_marketing_consent,
1133 flagged_answers_threshold,
1134 flagged_answers_skip_manual_review_and_allow_retry,
1135 closed_at,
1136 closed_additional_message,
1137 closed_course_successor_id,
1138 chapter_locking_enabled,
1139 cheater_detection_enabled,
1140 ai_policy,
1141 course_material_ai_instructions
1142FROM courses
1143WHERE id IN (SELECT * FROM UNNEST($1::uuid[]))
1144 AND deleted_at IS NULL
1145 "#,
1146 course_ids
1147 )
1148 .fetch_all(conn)
1149 .await?;
1150 Ok(courses)
1151}
1152
1153pub async fn get_by_organization_id(
1154 conn: &mut PgConnection,
1155 organization_id: Uuid,
1156) -> ModelResult<Vec<Course>> {
1157 let courses = sqlx::query_as!(
1158 Course,
1159 r#"
1160SELECT id,
1161 name,
1162 created_at,
1163 updated_at,
1164 organization_id,
1165 deleted_at,
1166 slug,
1167 content_search_language::text,
1168 language_code,
1169 copied_from,
1170 course_language_group_id,
1171 description,
1172 is_draft,
1173 is_test_mode,
1174 can_add_chatbot,
1175 is_unlisted,
1176 base_module_completion_requires_n_submodule_completions,
1177 is_joinable_by_code_only,
1178 join_code,
1179 ask_marketing_consent,
1180 flagged_answers_threshold,
1181 flagged_answers_skip_manual_review_and_allow_retry,
1182 closed_at,
1183 closed_additional_message,
1184 closed_course_successor_id,
1185 chapter_locking_enabled,
1186 cheater_detection_enabled,
1187 ai_policy,
1188 course_material_ai_instructions
1189FROM courses
1190WHERE organization_id = $1
1191 AND deleted_at IS NULL
1192ORDER BY name
1193 "#,
1194 organization_id
1195 )
1196 .fetch_all(conn)
1197 .await?;
1198 Ok(courses)
1199}
1200
1201pub async fn set_join_code_for_course(
1202 conn: &mut PgConnection,
1203 course_id: Uuid,
1204 join_code: String,
1205) -> ModelResult<()> {
1206 sqlx::query!(
1207 "
1208UPDATE courses
1209SET join_code = $2
1210WHERE id = $1
1211",
1212 course_id,
1213 join_code
1214 )
1215 .execute(conn)
1216 .await?;
1217 Ok(())
1218}
1219
1220pub async fn get_course_with_join_code(
1221 conn: &mut PgConnection,
1222 join_code: String,
1223) -> ModelResult<Course> {
1224 let course = sqlx::query_as!(
1225 Course,
1226 r#"
1227SELECT id,
1228 name,
1229 created_at,
1230 updated_at,
1231 organization_id,
1232 deleted_at,
1233 slug,
1234 content_search_language::text,
1235 language_code,
1236 copied_from,
1237 course_language_group_id,
1238 description,
1239 is_draft,
1240 is_test_mode,
1241 can_add_chatbot,
1242 is_unlisted,
1243 base_module_completion_requires_n_submodule_completions,
1244 is_joinable_by_code_only,
1245 join_code,
1246 ask_marketing_consent,
1247 flagged_answers_threshold,
1248 flagged_answers_skip_manual_review_and_allow_retry,
1249 closed_at,
1250 closed_additional_message,
1251 closed_course_successor_id,
1252 chapter_locking_enabled,
1253 cheater_detection_enabled,
1254 ai_policy,
1255 course_material_ai_instructions
1256FROM courses
1257WHERE join_code = $1
1258 AND deleted_at IS NULL;
1259 "#,
1260 join_code,
1261 )
1262 .fetch_one(conn)
1263 .await?;
1264 Ok(course)
1265}
1266
1267#[cfg(test)]
1268mod test {
1269 use super::*;
1270 use crate::{course_language_groups, courses, test_helper::*};
1271
1272 mod language_code_validation {
1273 use super::*;
1274
1275 #[tokio::test]
1276 async fn allows_valid_language_code() {
1277 insert_data!(:tx, user: _user, :org);
1278 let course_language_group_id = course_language_groups::insert(
1279 tx.as_mut(),
1280 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1281 "test-clg-allows-valid",
1282 )
1283 .await
1284 .unwrap();
1285 let new_course = create_new_course(org, "en-US");
1286 let res = courses::insert(
1287 tx.as_mut(),
1288 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1289 course_language_group_id,
1290 &new_course,
1291 )
1292 .await;
1293 assert!(res.is_ok());
1294 }
1295
1296 #[tokio::test]
1297 async fn disallows_empty_language_code() {
1298 insert_data!(:tx, user: _user, :org);
1299 let course_language_group_id = course_language_groups::insert(
1300 tx.as_mut(),
1301 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1302 "test-clg-disallows-empty",
1303 )
1304 .await
1305 .unwrap();
1306 let new_course = create_new_course(org, "");
1307 let res = courses::insert(
1308 tx.as_mut(),
1309 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1310 course_language_group_id,
1311 &new_course,
1312 )
1313 .await;
1314 assert!(res.is_err());
1315 }
1316
1317 #[tokio::test]
1318 async fn disallows_wrong_case_language_code() {
1319 insert_data!(:tx, user: _user, :org);
1320 let course_language_group_id = course_language_groups::insert(
1321 tx.as_mut(),
1322 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1323 "test-clg-disallows-wrong-case",
1324 )
1325 .await
1326 .unwrap();
1327 let new_course = create_new_course(org, "en-us");
1328 let res = courses::insert(
1329 tx.as_mut(),
1330 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1331 course_language_group_id,
1332 &new_course,
1333 )
1334 .await;
1335 assert!(res.is_err());
1336 }
1337
1338 #[tokio::test]
1339 async fn disallows_underscore_in_language_code() {
1340 insert_data!(:tx, user: _user, :org);
1341 let course_language_group_id = course_language_groups::insert(
1342 tx.as_mut(),
1343 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1344 "test-clg-disallows-underscore",
1345 )
1346 .await
1347 .unwrap();
1348 let new_course = create_new_course(org, "en_US");
1349 let res = courses::insert(
1350 tx.as_mut(),
1351 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1352 course_language_group_id,
1353 &new_course,
1354 )
1355 .await;
1356 assert!(res.is_err());
1357 }
1358
1359 fn create_new_course(organization_id: Uuid, language_code: &str) -> NewCourse {
1360 NewCourse {
1361 name: "".to_string(),
1362 slug: "".to_string(),
1363 organization_id,
1364 language_code: language_code.to_string(),
1365 teacher_in_charge_name: "teacher".to_string(),
1366 teacher_in_charge_email: "teacher@example.com".to_string(),
1367 description: "description".to_string(),
1368 is_draft: false,
1369 is_test_mode: false,
1370 is_unlisted: false,
1371 copy_user_permissions: false,
1372 is_joinable_by_code_only: false,
1373 join_code: None,
1374 ask_marketing_consent: false,
1375 flagged_answers_threshold: Some(3),
1376 can_add_chatbot: false,
1377 }
1378 }
1379 }
1380
1381 mod ai_policy {
1382 use super::*;
1383
1384 #[tokio::test]
1385 async fn update_course_round_trips_ai_policy_fields() {
1386 insert_data!(:tx, user: _user, :org);
1387 let course_language_group_id = course_language_groups::insert(
1388 tx.as_mut(),
1389 PKeyPolicy::Fixed(Uuid::parse_str("a1b2c3d4-0000-0000-0000-000000000001").unwrap()),
1390 "test-clg-ai-policy",
1391 )
1392 .await
1393 .unwrap();
1394 let new_course = NewCourse {
1395 name: "AI policy course".to_string(),
1396 slug: "ai-policy-course".to_string(),
1397 organization_id: org,
1398 language_code: "en-US".to_string(),
1399 teacher_in_charge_name: "teacher".to_string(),
1400 teacher_in_charge_email: "teacher@example.com".to_string(),
1401 description: "description".to_string(),
1402 is_draft: false,
1403 is_test_mode: false,
1404 is_unlisted: false,
1405 copy_user_permissions: false,
1406 is_joinable_by_code_only: false,
1407 join_code: None,
1408 ask_marketing_consent: false,
1409 flagged_answers_threshold: Some(3),
1410 can_add_chatbot: false,
1411 };
1412 let course_id = courses::insert(
1413 tx.as_mut(),
1414 PKeyPolicy::Fixed(Uuid::parse_str("a1b2c3d4-0000-0000-0000-000000000002").unwrap()),
1415 course_language_group_id,
1416 &new_course,
1417 )
1418 .await
1419 .unwrap();
1420
1421 let created = courses::get_course(tx.as_mut(), course_id).await.unwrap();
1423 assert_eq!(created.ai_policy, CourseAiPolicy::NotSet);
1424 assert_eq!(created.course_material_ai_instructions, None);
1425
1426 let updated = courses::update_course(
1428 tx.as_mut(),
1429 course_id,
1430 CourseUpdate {
1431 name: created.name.clone(),
1432 flagged_answers_threshold: 3,
1433 ai_policy: CourseAiPolicy::Limited,
1434 course_material_ai_instructions: Some(true),
1435 ..Default::default()
1436 },
1437 )
1438 .await
1439 .unwrap();
1440 assert_eq!(updated.ai_policy, CourseAiPolicy::Limited);
1441 assert_eq!(updated.course_material_ai_instructions, Some(true));
1442
1443 let reread = courses::get_course(tx.as_mut(), course_id).await.unwrap();
1445 assert_eq!(reread.ai_policy, CourseAiPolicy::Limited);
1446 assert_eq!(reread.course_material_ai_instructions, Some(true));
1447 }
1448 }
1449}
1450
1451#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
1452pub struct CourseMetadataUpdate {
1453 course_description: Option<String>,
1454 course_audiences: Vec<NewCourseAudience>,
1455 course_prerequisites: Vec<NewCoursePrerequisite>,
1456}
1457
1458#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
1459pub struct CourseMetadata {
1460 course_description: Option<String>,
1461 course_audiences: Vec<CourseAudience>,
1462 course_prerequisites: Vec<CoursePrerequisite>,
1463}
1464
1465pub async fn set_metadata(
1466 conn: &mut PgConnection,
1467 course_id: Uuid,
1468 course_metadata: CourseMetadataUpdate,
1469) -> ModelResult<CourseMetadata> {
1470 let old_prerequisites: Vec<CoursePrerequisite> =
1471 crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1472 let new_prerequisites: Vec<String> = course_metadata
1473 .course_prerequisites
1474 .iter()
1475 .map(|x| x.prerequisite.to_owned())
1476 .collect();
1477
1478 let prerequisite_old_strings: Vec<String> = old_prerequisites
1479 .iter()
1480 .map(|x| x.prerequisite.to_owned())
1481 .collect();
1482
1483 let prerequisites_to_delete: Vec<Uuid> = old_prerequisites
1484 .iter()
1485 .filter(|x| !new_prerequisites.contains(&x.prerequisite))
1486 .map(|x| x.id.to_owned())
1487 .collect();
1488
1489 let prerequisites_to_add: Vec<String> = new_prerequisites
1490 .into_iter()
1491 .filter(|x| !prerequisite_old_strings.contains(x))
1492 .collect();
1493
1494 crate::course_prerequisites::delete_batch(conn, prerequisites_to_delete).await?;
1495
1496 let prerequisites = insert_course_prerequisites(conn, course_id, prerequisites_to_add).await?;
1497
1498 let old_audiences: Vec<CourseAudience> =
1499 crate::course_audiences::get_by_course_id(conn, course_id).await?;
1500 let new_audiences: Vec<String> = course_metadata
1501 .course_audiences
1502 .iter()
1503 .map(|x| x.audience.to_owned())
1504 .collect();
1505
1506 let audience_old_strings: Vec<String> = old_audiences
1507 .iter()
1508 .map(|x| x.audience.to_owned())
1509 .collect();
1510
1511 let audiences_to_delete: Vec<Uuid> = old_audiences
1512 .iter()
1513 .filter(|x| !new_audiences.contains(&x.audience))
1514 .map(|x| x.id.to_owned())
1515 .collect();
1516
1517 let audiences_to_add: Vec<String> = new_audiences
1518 .into_iter()
1519 .filter(|x| !audience_old_strings.contains(x))
1520 .collect();
1521
1522 crate::course_audiences::delete_batch(conn, audiences_to_delete).await?;
1523 let audiences =
1524 crate::course_audiences::insert_course_audiences(conn, course_id, audiences_to_add).await?;
1525
1526 let course = get_course(conn, course_id).await?;
1527
1528 let update_payload = CourseUpdate {
1529 name: course.name,
1530 description: course_metadata.course_description,
1531 is_draft: course.is_draft,
1532 is_test_mode: course.is_test_mode,
1533 can_add_chatbot: course.can_add_chatbot,
1534 is_unlisted: course.is_unlisted,
1535 is_joinable_by_code_only: course.is_joinable_by_code_only,
1536 ask_marketing_consent: course.ask_marketing_consent,
1537 flagged_answers_threshold: course.flagged_answers_threshold.unwrap_or(0),
1538 flagged_answers_skip_manual_review_and_allow_retry: course
1539 .flagged_answers_skip_manual_review_and_allow_retry,
1540 closed_at: course.closed_at,
1541 closed_additional_message: course.closed_additional_message,
1542 closed_course_successor_id: course.closed_course_successor_id,
1543 chapter_locking_enabled: course.chapter_locking_enabled,
1544 ai_policy: course.ai_policy,
1545 course_material_ai_instructions: course.course_material_ai_instructions,
1546 };
1547 let updated_course = update_course(conn, course_id, update_payload).await?;
1548
1549 let res = CourseMetadata {
1550 course_description: updated_course.description,
1551 course_audiences: audiences,
1552 course_prerequisites: prerequisites,
1553 };
1554 Ok(res)
1555}
1556
1557#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
1558pub struct CompleteCourseMetadata {
1559 course: Course,
1560 course_instances: Vec<CourseInstance>,
1561 default_module: CourseModule,
1562 course_prerequisites: Vec<CoursePrerequisite>,
1563 course_audiences: Vec<CourseAudience>,
1564 course_organization: DatabaseOrganization,
1565}
1566
1567pub async fn get_metadata(
1568 conn: &mut PgConnection,
1569 course_id: Uuid,
1570) -> ModelResult<CompleteCourseMetadata> {
1571 let prerequisites: Vec<CoursePrerequisite> =
1572 crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1573 let audiences: Vec<CourseAudience> =
1574 crate::course_audiences::get_by_course_id(conn, course_id).await?;
1575 let course_data = get_course(conn, course_id).await?;
1576 let instances =
1577 crate::course_instances::get_course_instances_for_course(conn, course_id).await?;
1578 let module = crate::course_modules::get_default_by_course_id(conn, course_id).await?;
1579
1580 let organization =
1581 crate::organizations::get_organization(conn, course_data.organization_id).await?;
1582
1583 let metadata = CompleteCourseMetadata {
1584 course: course_data,
1585 course_instances: instances,
1586 default_module: module,
1587 course_prerequisites: prerequisites,
1588 course_audiences: audiences,
1589 course_organization: organization,
1590 };
1591 Ok(metadata)
1592}