Skip to main content

headless_lms_models/
courses.rs

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