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