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