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(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
29
30pub struct Course {
31 pub id: Uuid,
32 pub slug: String,
33 pub created_at: DateTime<Utc>,
34 pub updated_at: DateTime<Utc>,
35 pub name: String,
36 pub description: Option<String>,
37 pub organization_id: Uuid,
38 pub deleted_at: Option<DateTime<Utc>>,
39 pub language_code: String,
40 pub copied_from: Option<Uuid>,
41 pub content_search_language: Option<String>,
42 pub course_language_group_id: Uuid,
43 pub is_draft: bool,
44 pub is_test_mode: bool,
45 pub is_unlisted: bool,
46 pub base_module_completion_requires_n_submodule_completions: i32,
47 pub can_add_chatbot: bool,
48 pub is_joinable_by_code_only: bool,
49 pub join_code: Option<String>,
50 pub ask_marketing_consent: bool,
51 pub flagged_answers_threshold: Option<i32>,
52 pub flagged_answers_skip_manual_review_and_allow_retry: bool,
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, ToSchema)]
61
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, ToSchema)]
113
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, ToSchema)]
145
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, ToSchema)]
156
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, ToSchema)]
236
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 flagged_answers_skip_manual_review_and_allow_retry,
270 closed_at,
271 closed_additional_message,
272 closed_course_successor_id,
273 chapter_locking_enabled
274FROM courses
275WHERE deleted_at IS NULL;
276"#
277 )
278 .fetch_all(conn)
279 .await?;
280 Ok(courses)
281}
282
283pub async fn all_courses_user_enrolled_to(
284 conn: &mut PgConnection,
285 user_id: Uuid,
286) -> ModelResult<Vec<Course>> {
287 let courses = sqlx::query_as!(
288 Course,
289 r#"
290SELECT id,
291 name,
292 created_at,
293 updated_at,
294 organization_id,
295 deleted_at,
296 slug,
297 content_search_language::text,
298 language_code,
299 copied_from,
300 course_language_group_id,
301 description,
302 is_draft,
303 is_test_mode,
304 is_unlisted,
305 base_module_completion_requires_n_submodule_completions,
306 can_add_chatbot,
307 is_joinable_by_code_only,
308 join_code,
309 ask_marketing_consent,
310 flagged_answers_threshold,
311 flagged_answers_skip_manual_review_and_allow_retry,
312 closed_at,
313 closed_additional_message,
314 closed_course_successor_id,
315 chapter_locking_enabled
316FROM courses
317WHERE courses.deleted_at IS NULL
318 AND id IN (
319 SELECT current_course_id
320 FROM user_course_settings
321 WHERE deleted_at IS NULL
322 AND user_id = $1
323 )
324"#,
325 user_id
326 )
327 .fetch_all(conn)
328 .await?;
329 Ok(courses)
330}
331
332pub async fn all_courses_with_roles_for_user(
333 conn: &mut PgConnection,
334 user_id: Uuid,
335) -> ModelResult<Vec<Course>> {
336 let courses = sqlx::query_as!(
337 Course,
338 r#"
339SELECT id,
340 name,
341 created_at,
342 updated_at,
343 organization_id,
344 deleted_at,
345 slug,
346 content_search_language::text,
347 language_code,
348 copied_from,
349 course_language_group_id,
350 description,
351 is_draft,
352 is_test_mode,
353 can_add_chatbot,
354 is_unlisted,
355 base_module_completion_requires_n_submodule_completions,
356 is_joinable_by_code_only,
357 join_code,
358 ask_marketing_consent,
359 flagged_answers_threshold,
360 flagged_answers_skip_manual_review_and_allow_retry,
361 closed_at,
362 closed_additional_message,
363 closed_course_successor_id,
364 chapter_locking_enabled
365FROM courses
366WHERE courses.deleted_at IS NULL
367 AND (
368 id IN (
369 SELECT course_id
370 FROM roles
371 WHERE deleted_at IS NULL
372 AND user_id = $1
373 AND course_id IS NOT NULL
374 )
375 OR (
376 id IN (
377 SELECT ci.course_id
378 FROM course_instances ci
379 JOIN ROLES r ON r.course_instance_id = ci.id
380 WHERE r.user_id = $1
381 AND r.deleted_at IS NULL
382 AND ci.deleted_at IS NULL
383 )
384 )
385 ) "#,
386 user_id
387 )
388 .fetch_all(conn)
389 .await?;
390 Ok(courses)
391}
392
393pub async fn get_all_language_versions_of_course(
394 conn: &mut PgConnection,
395 course: &Course,
396) -> ModelResult<Vec<Course>> {
397 let courses = sqlx::query_as!(
398 Course,
399 "
400SELECT id,
401 name,
402 created_at,
403 updated_at,
404 organization_id,
405 deleted_at,
406 slug,
407 content_search_language::text,
408 language_code,
409 copied_from,
410 course_language_group_id,
411 description,
412 is_draft,
413 is_test_mode,
414 base_module_completion_requires_n_submodule_completions,
415 can_add_chatbot,
416 is_unlisted,
417 is_joinable_by_code_only,
418 join_code,
419 ask_marketing_consent,
420 flagged_answers_threshold,
421 flagged_answers_skip_manual_review_and_allow_retry,
422 closed_at,
423 closed_additional_message,
424 closed_course_successor_id,
425 chapter_locking_enabled
426FROM courses
427WHERE course_language_group_id = $1
428AND deleted_at IS NULL
429 ",
430 course.course_language_group_id,
431 )
432 .fetch_all(conn)
433 .await?;
434 Ok(courses)
435}
436
437pub async fn get_active_courses_for_organization(
438 conn: &mut PgConnection,
439 organization_id: Uuid,
440 pagination: Pagination,
441) -> ModelResult<Vec<Course>> {
442 let course_instances = sqlx::query_as!(
443 Course,
444 r#"
445SELECT
446 DISTINCT(c.id),
447 c.name,
448 c.created_at,
449 c.updated_at,
450 c.organization_id,
451 c.deleted_at,
452 c.slug,
453 c.content_search_language::text,
454 c.language_code,
455 c.copied_from,
456 c.course_language_group_id,
457 c.description,
458 c.is_draft,
459 c.is_test_mode,
460 c.base_module_completion_requires_n_submodule_completions,
461 c.can_add_chatbot,
462 c.is_unlisted,
463 c.is_joinable_by_code_only,
464 c.join_code,
465 c.ask_marketing_consent,
466 c.flagged_answers_threshold,
467 c.flagged_answers_skip_manual_review_and_allow_retry,
468 c.closed_at,
469 c.closed_additional_message,
470 c.closed_course_successor_id,
471 c.chapter_locking_enabled
472FROM courses as c
473 LEFT JOIN course_instances as ci on c.id = ci.course_id
474WHERE
475 c.organization_id = $1 AND
476 ci.starts_at < NOW() AND ci.ends_at > NOW() AND
477 c.deleted_at IS NULL AND ci.deleted_at IS NULL
478 LIMIT $2 OFFSET $3;
479 "#,
480 organization_id,
481 pagination.limit(),
482 pagination.offset()
483 )
484 .fetch_all(conn)
485 .await?;
486 Ok(course_instances)
487}
488
489pub async fn get_active_courses_for_organization_count(
490 conn: &mut PgConnection,
491 organization_id: Uuid,
492) -> ModelResult<CourseCount> {
493 let result = sqlx::query!(
494 r#"
495SELECT
496 COUNT(DISTINCT c.id) as count
497FROM courses as c
498 LEFT JOIN course_instances as ci on c.id = ci.course_id
499WHERE
500 c.organization_id = $1 AND
501 ci.starts_at < NOW() AND ci.ends_at > NOW() AND
502 c.deleted_at IS NULL AND ci.deleted_at IS NULL;
503 "#,
504 organization_id
505 )
506 .fetch_one(conn)
507 .await?;
508 Ok(CourseCount {
509 count: result.count.unwrap_or_default().try_into()?,
510 })
511}
512
513pub async fn get_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
514 let course = sqlx::query_as!(
515 Course,
516 r#"
517SELECT id,
518 name,
519 created_at,
520 updated_at,
521 organization_id,
522 deleted_at,
523 slug,
524 content_search_language::text,
525 language_code,
526 copied_from,
527 course_language_group_id,
528 description,
529 is_draft,
530 is_test_mode,
531 can_add_chatbot,
532 is_unlisted,
533 base_module_completion_requires_n_submodule_completions,
534 is_joinable_by_code_only,
535 join_code,
536 ask_marketing_consent,
537 flagged_answers_threshold,
538 flagged_answers_skip_manual_review_and_allow_retry,
539 closed_at,
540 closed_additional_message,
541 closed_course_successor_id,
542 chapter_locking_enabled
543FROM courses
544WHERE id = $1;
545 "#,
546 course_id
547 )
548 .fetch_one(conn)
549 .await?;
550 Ok(course)
551}
552
553pub async fn get_by_id_and_join_code(
554 conn: &mut PgConnection,
555 course_id: Uuid,
556 join_code: &str,
557) -> 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
587FROM courses
588WHERE id = $1
589 AND join_code = $2
590 AND deleted_at IS NULL;
591 "#,
592 course_id,
593 join_code,
594 )
595 .fetch_one(conn)
596 .await?;
597 Ok(course)
598}
599
600pub async fn get_course_breadcrumb_info(
601 conn: &mut PgConnection,
602 course_id: Uuid,
603) -> ModelResult<CourseBreadcrumbInfo> {
604 let res = sqlx::query_as!(
605 CourseBreadcrumbInfo,
606 r#"
607SELECT courses.id as course_id,
608 courses.name as course_name,
609 courses.slug as course_slug,
610 organizations.slug as organization_slug,
611 organizations.name as organization_name
612FROM courses
613 JOIN organizations ON (courses.organization_id = organizations.id)
614WHERE courses.id = $1;
615 "#,
616 course_id
617 )
618 .fetch_one(conn)
619 .await?;
620 Ok(res)
621}
622
623pub async fn get_nondeleted_course_id_by_slug(
624 conn: &mut PgConnection,
625 slug: &str,
626) -> ModelResult<CourseContextData> {
627 let data = sqlx::query_as!(
628 CourseContextData,
629 "SELECT id, is_test_mode FROM courses WHERE slug = $1 AND deleted_at IS NULL",
630 slug
631 )
632 .fetch_one(conn)
633 .await?;
634 Ok(data)
635}
636
637pub async fn get_organization_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
638 let organization_id = sqlx::query!("SELECT organization_id FROM courses WHERE id = $1", id)
639 .fetch_one(conn)
640 .await?
641 .organization_id;
642 Ok(organization_id)
643}
644
645pub async fn get_course_structure(
647 conn: &mut PgConnection,
648 course_id: Uuid,
649 file_store: &dyn FileStore,
650 app_conf: &ApplicationConfiguration,
651) -> ModelResult<CourseStructure> {
652 let course = get_course(conn, course_id).await?;
653 let pages = get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
654 let chapters = course_chapters(conn, course_id)
655 .await?
656 .iter()
657 .map(|chapter| Chapter::from_database_chapter(chapter, file_store, app_conf))
658 .collect();
659 let modules = crate::course_modules::get_by_course_id(conn, course_id).await?;
660 Ok(CourseStructure {
661 course,
662 pages,
663 chapters,
664 modules,
665 })
666}
667
668pub async fn organization_courses_visible_to_user_paginated(
669 conn: &mut PgConnection,
670 organization_id: Uuid,
671 user: Option<Uuid>,
672 pagination: Pagination,
673) -> ModelResult<Vec<Course>> {
674 let courses = sqlx::query_as!(
675 Course,
676 r#"
677SELECT courses.id,
678 courses.name,
679 courses.created_at,
680 courses.updated_at,
681 courses.organization_id,
682 courses.deleted_at,
683 courses.slug,
684 courses.content_search_language::text,
685 courses.language_code,
686 courses.copied_from,
687 courses.course_language_group_id,
688 courses.description,
689 courses.is_draft,
690 courses.is_test_mode,
691 base_module_completion_requires_n_submodule_completions,
692 can_add_chatbot,
693 courses.is_unlisted,
694 courses.is_joinable_by_code_only,
695 courses.join_code,
696 courses.ask_marketing_consent,
697 courses.flagged_answers_threshold,
698 courses.flagged_answers_skip_manual_review_and_allow_retry,
699 courses.closed_at,
700 courses.closed_additional_message,
701 courses.closed_course_successor_id,
702 courses.chapter_locking_enabled
703FROM courses
704WHERE courses.organization_id = $1
705 AND (
706 (
707 courses.is_draft IS FALSE
708 AND courses.is_unlisted IS FALSE
709 )
710 OR EXISTS (
711 SELECT id
712 FROM roles
713 WHERE user_id = $2
714 AND (
715 course_id = courses.id
716 OR roles.organization_id = courses.organization_id
717 OR roles.is_global IS TRUE
718 )
719 )
720 )
721 AND courses.deleted_at IS NULL
722ORDER BY courses.name
723LIMIT $3 OFFSET $4;
724"#,
725 organization_id,
726 user,
727 pagination.limit(),
728 pagination.offset()
729 )
730 .fetch_all(conn)
731 .await?;
732 Ok(courses)
733}
734
735pub async fn organization_course_count(
736 conn: &mut PgConnection,
737 organization_id: Uuid,
738) -> ModelResult<CourseCount> {
739 let course_count = sqlx::query!(
740 r#"
741SELECT
742 COUNT(DISTINCT id) as count
743FROM courses
744WHERE organization_id = $1
745 AND deleted_at IS NULL;
746 "#,
747 organization_id,
748 )
749 .fetch_one(conn)
750 .await?;
751 Ok(CourseCount {
752 count: course_count.count.unwrap_or_default().try_into()?,
753 })
754}
755#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default, ToSchema)]
757
758pub struct CourseUpdate {
759 pub name: String,
760 pub description: Option<String>,
761 pub is_draft: bool,
762 pub is_test_mode: bool,
763 pub can_add_chatbot: bool,
764 pub is_unlisted: bool,
765 pub is_joinable_by_code_only: bool,
766 pub ask_marketing_consent: bool,
767 pub flagged_answers_threshold: i32,
768 pub flagged_answers_skip_manual_review_and_allow_retry: bool,
769 pub closed_at: Option<DateTime<Utc>>,
770 pub closed_additional_message: Option<String>,
771 pub closed_course_successor_id: Option<Uuid>,
772 pub chapter_locking_enabled: bool,
773}
774
775pub async fn update_course(
776 conn: &mut PgConnection,
777 course_id: Uuid,
778 course_update: CourseUpdate,
779) -> ModelResult<Course> {
780 let res = sqlx::query_as!(
781 Course,
782 r#"
783UPDATE courses
784SET name = $1,
785 description = $2,
786 is_draft = $3,
787 is_test_mode = $4,
788 can_add_chatbot = $5,
789 is_unlisted = $6,
790 is_joinable_by_code_only = $7,
791 ask_marketing_consent = $8,
792 flagged_answers_threshold = $9,
793 flagged_answers_skip_manual_review_and_allow_retry = $10,
794 closed_at = $11,
795 closed_additional_message = $12,
796 closed_course_successor_id = $13,
797 chapter_locking_enabled = $14
798WHERE id = $15
799 AND deleted_at IS NULL
800RETURNING id,
801 name,
802 created_at,
803 updated_at,
804 organization_id,
805 deleted_at,
806 slug,
807 content_search_language::text,
808 language_code,
809 copied_from,
810 course_language_group_id,
811 description,
812 is_draft,
813 is_test_mode,
814 can_add_chatbot,
815 is_unlisted,
816 base_module_completion_requires_n_submodule_completions,
817 is_joinable_by_code_only,
818 join_code,
819 ask_marketing_consent,
820 flagged_answers_threshold,
821 flagged_answers_skip_manual_review_and_allow_retry,
822 closed_at,
823 closed_additional_message,
824 closed_course_successor_id,
825 chapter_locking_enabled
826 "#,
827 course_update.name,
828 course_update.description,
829 course_update.is_draft,
830 course_update.is_test_mode,
831 course_update.can_add_chatbot,
832 course_update.is_unlisted,
833 course_update.is_joinable_by_code_only,
834 course_update.ask_marketing_consent,
835 course_update.flagged_answers_threshold,
836 course_update.flagged_answers_skip_manual_review_and_allow_retry,
837 course_update.closed_at,
838 course_update.closed_additional_message,
839 course_update.closed_course_successor_id,
840 course_update.chapter_locking_enabled,
841 course_id
842 )
843 .fetch_one(conn)
844 .await?;
845 Ok(res)
846}
847
848pub async fn update_course_base_module_completion_count_requirement(
849 conn: &mut PgConnection,
850 id: Uuid,
851 base_module_completion_requires_n_submodule_completions: i32,
852) -> ModelResult<bool> {
853 let res = sqlx::query!(
854 "
855UPDATE courses
856SET base_module_completion_requires_n_submodule_completions = $1
857WHERE id = $2
858 AND deleted_at IS NULL
859 ",
860 base_module_completion_requires_n_submodule_completions,
861 id,
862 )
863 .execute(conn)
864 .await?;
865 Ok(res.rows_affected() > 0)
866}
867
868pub async fn delete_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
869 let deleted = sqlx::query_as!(
870 Course,
871 r#"
872UPDATE courses
873SET deleted_at = now()
874WHERE id = $1
875AND deleted_at IS NULL
876RETURNING id,
877 name,
878 created_at,
879 updated_at,
880 organization_id,
881 deleted_at,
882 slug,
883 content_search_language::text,
884 language_code,
885 copied_from,
886 course_language_group_id,
887 description,
888 is_draft,
889 is_test_mode,
890 can_add_chatbot,
891 is_unlisted,
892 base_module_completion_requires_n_submodule_completions,
893 is_joinable_by_code_only,
894 join_code,
895 ask_marketing_consent,
896 flagged_answers_threshold,
897 flagged_answers_skip_manual_review_and_allow_retry,
898 closed_at,
899 closed_additional_message,
900 closed_course_successor_id,
901 chapter_locking_enabled
902 "#,
903 course_id
904 )
905 .fetch_one(conn)
906 .await?;
907 Ok(deleted)
908}
909
910pub async fn get_course_by_slug(conn: &mut PgConnection, course_slug: &str) -> ModelResult<Course> {
911 let course = sqlx::query_as!(
912 Course,
913 "
914SELECT id,
915 name,
916 created_at,
917 updated_at,
918 organization_id,
919 deleted_at,
920 slug,
921 content_search_language::text,
922 language_code,
923 copied_from,
924 course_language_group_id,
925 description,
926 is_draft,
927 is_test_mode,
928 can_add_chatbot,
929 is_unlisted,
930 base_module_completion_requires_n_submodule_completions,
931 is_joinable_by_code_only,
932 join_code,
933 ask_marketing_consent,
934 flagged_answers_threshold,
935 flagged_answers_skip_manual_review_and_allow_retry,
936 closed_at,
937 closed_additional_message,
938 closed_course_successor_id,
939 chapter_locking_enabled
940FROM courses
941WHERE slug = $1
942 AND deleted_at IS NULL
943",
944 course_slug,
945 )
946 .fetch_one(conn)
947 .await?;
948 Ok(course)
949}
950
951pub async fn get_cfgname_by_tag(
952 conn: &mut PgConnection,
953 ietf_language_tag: String,
954) -> ModelResult<String> {
955 let tag = ietf_language_tag
956 .split('-')
957 .next()
958 .unwrap_or_else(|| &ietf_language_tag[..]);
959
960 let lang_name = LANGUAGE_TAG_TO_NAME.get(&tag);
961
962 let name = sqlx::query!(
963 "SELECT cfgname::text FROM pg_ts_config WHERE cfgname = $1",
964 lang_name
965 )
966 .fetch_optional(conn)
967 .await?;
968
969 let res = name
970 .and_then(|n| n.cfgname)
971 .unwrap_or_else(|| "simple".to_string());
972
973 Ok(res)
974}
975
976pub async fn is_draft(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
977 let res = sqlx::query!(
978 "
979SELECT is_draft
980FROM courses
981WHERE id = $1
982",
983 id
984 )
985 .fetch_one(conn)
986 .await?;
987 Ok(res.is_draft)
988}
989
990pub async fn is_joinable_by_code_only(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
991 let res = sqlx::query!(
992 "
993SELECT is_joinable_by_code_only
994FROM courses
995WHERE id = $1
996",
997 id
998 )
999 .fetch_one(conn)
1000 .await?;
1001 Ok(res.is_joinable_by_code_only)
1002}
1003
1004pub(crate) async fn get_by_ids(
1005 conn: &mut PgConnection,
1006 course_ids: &[Uuid],
1007) -> ModelResult<Vec<Course>> {
1008 let courses = sqlx::query_as!(
1009 Course,
1010 "
1011SELECT id,
1012 name,
1013 created_at,
1014 updated_at,
1015 organization_id,
1016 deleted_at,
1017 slug,
1018 content_search_language::text,
1019 language_code,
1020 copied_from,
1021 course_language_group_id,
1022 description,
1023 is_draft,
1024 is_test_mode,
1025 can_add_chatbot,
1026 is_unlisted,
1027 base_module_completion_requires_n_submodule_completions,
1028 is_joinable_by_code_only,
1029 join_code,
1030 ask_marketing_consent,
1031 flagged_answers_threshold,
1032 flagged_answers_skip_manual_review_and_allow_retry,
1033 closed_at,
1034 closed_additional_message,
1035 closed_course_successor_id,
1036 chapter_locking_enabled
1037FROM courses
1038WHERE id IN (SELECT * FROM UNNEST($1::uuid[]))
1039 AND deleted_at IS NULL
1040 ",
1041 course_ids
1042 )
1043 .fetch_all(conn)
1044 .await?;
1045 Ok(courses)
1046}
1047
1048pub async fn get_by_organization_id(
1049 conn: &mut PgConnection,
1050 organization_id: Uuid,
1051) -> ModelResult<Vec<Course>> {
1052 let courses = sqlx::query_as!(
1053 Course,
1054 r#"
1055SELECT id,
1056 name,
1057 created_at,
1058 updated_at,
1059 organization_id,
1060 deleted_at,
1061 slug,
1062 content_search_language::text,
1063 language_code,
1064 copied_from,
1065 course_language_group_id,
1066 description,
1067 is_draft,
1068 is_test_mode,
1069 can_add_chatbot,
1070 is_unlisted,
1071 base_module_completion_requires_n_submodule_completions,
1072 is_joinable_by_code_only,
1073 join_code,
1074 ask_marketing_consent,
1075 flagged_answers_threshold,
1076 flagged_answers_skip_manual_review_and_allow_retry,
1077 closed_at,
1078 closed_additional_message,
1079 closed_course_successor_id,
1080 chapter_locking_enabled
1081FROM courses
1082WHERE organization_id = $1
1083 AND deleted_at IS NULL
1084ORDER BY name
1085 "#,
1086 organization_id
1087 )
1088 .fetch_all(conn)
1089 .await?;
1090 Ok(courses)
1091}
1092
1093pub async fn set_join_code_for_course(
1094 conn: &mut PgConnection,
1095 course_id: Uuid,
1096 join_code: String,
1097) -> ModelResult<()> {
1098 sqlx::query!(
1099 "
1100UPDATE courses
1101SET join_code = $2
1102WHERE id = $1
1103",
1104 course_id,
1105 join_code
1106 )
1107 .execute(conn)
1108 .await?;
1109 Ok(())
1110}
1111
1112pub async fn get_course_with_join_code(
1113 conn: &mut PgConnection,
1114 join_code: String,
1115) -> ModelResult<Course> {
1116 let course = sqlx::query_as!(
1117 Course,
1118 r#"
1119SELECT id,
1120 name,
1121 created_at,
1122 updated_at,
1123 organization_id,
1124 deleted_at,
1125 slug,
1126 content_search_language::text,
1127 language_code,
1128 copied_from,
1129 course_language_group_id,
1130 description,
1131 is_draft,
1132 is_test_mode,
1133 can_add_chatbot,
1134 is_unlisted,
1135 base_module_completion_requires_n_submodule_completions,
1136 is_joinable_by_code_only,
1137 join_code,
1138 ask_marketing_consent,
1139 flagged_answers_threshold,
1140 flagged_answers_skip_manual_review_and_allow_retry,
1141 closed_at,
1142 closed_additional_message,
1143 closed_course_successor_id,
1144 chapter_locking_enabled
1145FROM courses
1146WHERE join_code = $1
1147 AND deleted_at IS NULL;
1148 "#,
1149 join_code,
1150 )
1151 .fetch_one(conn)
1152 .await?;
1153 Ok(course)
1154}
1155
1156#[cfg(test)]
1157mod test {
1158 use super::*;
1159 use crate::{course_language_groups, courses, test_helper::*};
1160
1161 mod language_code_validation {
1162 use super::*;
1163
1164 #[tokio::test]
1165 async fn allows_valid_language_code() {
1166 insert_data!(:tx, user: _user, :org);
1167 let course_language_group_id = course_language_groups::insert(
1168 tx.as_mut(),
1169 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1170 "test-clg-allows-valid",
1171 )
1172 .await
1173 .unwrap();
1174 let new_course = create_new_course(org, "en-US");
1175 let res = courses::insert(
1176 tx.as_mut(),
1177 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1178 course_language_group_id,
1179 &new_course,
1180 )
1181 .await;
1182 assert!(res.is_ok());
1183 }
1184
1185 #[tokio::test]
1186 async fn disallows_empty_language_code() {
1187 insert_data!(:tx, user: _user, :org);
1188 let course_language_group_id = course_language_groups::insert(
1189 tx.as_mut(),
1190 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1191 "test-clg-disallows-empty",
1192 )
1193 .await
1194 .unwrap();
1195 let new_course = create_new_course(org, "");
1196 let res = courses::insert(
1197 tx.as_mut(),
1198 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1199 course_language_group_id,
1200 &new_course,
1201 )
1202 .await;
1203 assert!(res.is_err());
1204 }
1205
1206 #[tokio::test]
1207 async fn disallows_wrong_case_language_code() {
1208 insert_data!(:tx, user: _user, :org);
1209 let course_language_group_id = course_language_groups::insert(
1210 tx.as_mut(),
1211 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1212 "test-clg-disallows-wrong-case",
1213 )
1214 .await
1215 .unwrap();
1216 let new_course = create_new_course(org, "en-us");
1217 let res = courses::insert(
1218 tx.as_mut(),
1219 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1220 course_language_group_id,
1221 &new_course,
1222 )
1223 .await;
1224 assert!(res.is_err());
1225 }
1226
1227 #[tokio::test]
1228 async fn disallows_underscore_in_language_code() {
1229 insert_data!(:tx, user: _user, :org);
1230 let course_language_group_id = course_language_groups::insert(
1231 tx.as_mut(),
1232 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1233 "test-clg-disallows-underscore",
1234 )
1235 .await
1236 .unwrap();
1237 let new_course = create_new_course(org, "en_US");
1238 let res = courses::insert(
1239 tx.as_mut(),
1240 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1241 course_language_group_id,
1242 &new_course,
1243 )
1244 .await;
1245 assert!(res.is_err());
1246 }
1247
1248 fn create_new_course(organization_id: Uuid, language_code: &str) -> NewCourse {
1249 NewCourse {
1250 name: "".to_string(),
1251 slug: "".to_string(),
1252 organization_id,
1253 language_code: language_code.to_string(),
1254 teacher_in_charge_name: "teacher".to_string(),
1255 teacher_in_charge_email: "teacher@example.com".to_string(),
1256 description: "description".to_string(),
1257 is_draft: false,
1258 is_test_mode: false,
1259 is_unlisted: false,
1260 copy_user_permissions: false,
1261 is_joinable_by_code_only: false,
1262 join_code: None,
1263 ask_marketing_consent: false,
1264 flagged_answers_threshold: Some(3),
1265 can_add_chatbot: false,
1266 }
1267 }
1268 }
1269}