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