Skip to main content

headless_lms_models/
courses.rs

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/// The AI policy a teacher has selected for a course. Drives which variant of the student-facing
29/// AI usage notice is shown; `NotSet` (the default) keeps the generic default message.
30#[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    /// No policy selected; the notice shows the generic default message.
36    #[default]
37    NotSet,
38    /// AI is not allowed at any point.
39    NoAi,
40    /// AI may be used for planning (brainstorming/outlining) but not in the final work.
41    PlanningOnly,
42    /// AI may be used for specific tasks only, with disclosure.
43    Limited,
44    /// AI may be used freely; the student directs it and discloses their use.
45    FullUse,
46    /// AI use is expected or mandatory.
47    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/** A subset of the `Course` struct that contains the fields that are allowed to be shown to all students on the course materials. */
85#[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/** All the necessary info that can be used to switch the user's browser to a different language version of the course. */
141#[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    /// Creates a new `CourseLanguageVersionNavigationInfo` from a course and page language group navigation info.
155    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/// Represents the subset of page fields that are required to create a new course.
184#[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    /// Name of the teacher who is responsible for the course. Must be a valid name.
192    pub teacher_in_charge_name: String,
193    /// Email of the teacher who is responsible for the course. Must be a valid email.
194    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    /// If true, copies all user permissions from the original course to the new one.
200    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
697/// Gets full course structure including all the pages.
698pub 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// Represents the subset of page fields that one is allowed to update in a course
811#[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
912/// Enables or disables suspected-cheater detection for a single course. Used by the seed routine to
913/// turn detection off for seeded courses (which are completed in seconds and would otherwise flag
914/// every seeded user); production courses keep the on-by-default value set at creation.
915pub 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            // New courses default to the generic notice (NotSet / Unknown).
1413            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            // A teacher selects a policy and indicates the material has its own AI instructions.
1418            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            // The change is persisted for subsequent reads (which feed the student dialog).
1435            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}