Skip to main content

headless_lms_models/
courses.rs

1use std::collections::HashMap;
2
3use crate::{
4    chapters::{Chapter, get_course_chapters},
5    course_audiences::{CourseAudience, EditCourseAudience, upsert_course_audiences},
6    course_instances::CourseInstance,
7    course_modules::{CourseAuditingModuleUpdate, CourseModule},
8    course_prerequisites::{
9        CoursePrerequisite, EditCoursePrerequisite, upsert_course_prerequisites,
10    },
11    entity_history::{self, HistoryAuthor},
12    error::missing_model_error,
13    library::entity_snapshots::{
14        self, ContentEntityType, CourseAudienceSnapshot, CourseSettingsSnapshot,
15    },
16    organizations::DatabaseOrganization,
17    pages::{Page, PageVisibility, get_all_by_course_id_and_visibility},
18    prelude::*,
19};
20use headless_lms_utils::{
21    azure_embedding::create_embeddings, file_store::FileStore,
22    language_tag_to_name::LANGUAGE_TAG_TO_NAME, strings::non_empty_trimmed,
23};
24use pgvector::Vector;
25use utoipa::ToSchema;
26pub struct CourseInfo {
27    pub id: Uuid,
28    pub is_draft: bool,
29}
30
31#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
32
33pub struct CourseCount {
34    pub count: u32,
35}
36
37pub struct CourseContextData {
38    pub id: Uuid,
39    pub is_test_mode: bool,
40}
41
42#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
43pub struct CourseMetadata {
44    course_description: Option<String>,
45    course_audiences: Vec<CourseAudience>,
46    course_prerequisites: Vec<CoursePrerequisite>,
47    course_updated_at: DateTime<Utc>,
48}
49
50#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
51pub struct CourseMetadataUpdate {
52    course_description: Option<String>,
53    course_audiences: Vec<EditCourseAudience>,
54    course_prerequisites: Vec<EditCoursePrerequisite>,
55}
56
57#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
58pub struct CompleteCourseMetadata {
59    course: Course,
60    course_instances: Vec<CourseInstance>,
61    default_module: CourseModule,
62    course_prerequisites: Vec<CoursePrerequisite>,
63    course_audiences: Vec<CourseAudience>,
64    course_organization: DatabaseOrganization,
65}
66
67/// The AI policy a teacher has selected for a course. Drives which variant of the student-facing
68/// AI usage notice is shown; `NotSet` (the default) keeps the generic default message.
69#[derive(
70    Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default, sqlx::Type, ToSchema,
71)]
72#[sqlx(type_name = "course_ai_policy", rename_all = "snake_case")]
73pub enum CourseAiPolicy {
74    /// No policy selected; the notice shows the generic default message.
75    #[default]
76    NotSet,
77    /// AI is not allowed at any point.
78    NoAi,
79    /// AI may be used for planning (brainstorming/outlining) but not in the final work.
80    PlanningOnly,
81    /// AI may be used for specific tasks only, with disclosure.
82    Limited,
83    /// AI may be used freely; the student directs it and discloses their use.
84    FullUse,
85    /// AI use is expected or mandatory.
86    Required,
87}
88
89#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
90
91pub struct Course {
92    pub id: Uuid,
93    pub slug: String,
94    pub created_at: DateTime<Utc>,
95    pub updated_at: DateTime<Utc>,
96    pub name: String,
97    pub description: Option<String>,
98    pub organization_id: Uuid,
99    pub deleted_at: Option<DateTime<Utc>>,
100    pub language_code: String,
101    pub copied_from: Option<Uuid>,
102    pub content_search_language: Option<String>,
103    pub course_language_group_id: Uuid,
104    pub is_draft: bool,
105    pub is_test_mode: bool,
106    pub is_unlisted: bool,
107    pub base_module_completion_requires_n_submodule_completions: i32,
108    pub can_add_chatbot: bool,
109    pub is_joinable_by_code_only: bool,
110    pub join_code: Option<String>,
111    pub ask_marketing_consent: bool,
112    pub flagged_answers_threshold: Option<i32>,
113    pub flagged_answers_skip_manual_review_and_allow_retry: bool,
114    pub closed_at: Option<DateTime<Utc>>,
115    pub closed_additional_message: Option<String>,
116    pub closed_course_successor_id: Option<Uuid>,
117    pub chapter_locking_enabled: bool,
118    pub cheater_detection_enabled: bool,
119    pub ai_policy: CourseAiPolicy,
120    pub course_material_ai_instructions: Option<bool>,
121}
122impl Course {
123    pub fn is_closed(&self) -> bool {
124        if let Some(closed_at) = self.closed_at {
125            let now = chrono::Utc::now();
126            closed_at <= now
127        } else {
128            false
129        }
130    }
131}
132
133/** A subset of the `Course` struct that contains the fields that are allowed to be shown to all students on the course materials. */
134#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
135
136pub struct CourseMaterialCourse {
137    pub id: Uuid,
138    pub slug: String,
139    pub name: String,
140    pub description: Option<String>,
141    pub organization_id: Uuid,
142    pub language_code: String,
143    pub copied_from: Option<Uuid>,
144    pub content_search_language: Option<String>,
145    pub course_language_group_id: Uuid,
146    pub is_draft: bool,
147    pub is_test_mode: bool,
148    pub is_unlisted: bool,
149    pub base_module_completion_requires_n_submodule_completions: i32,
150    pub is_joinable_by_code_only: bool,
151    pub ask_marketing_consent: bool,
152    pub closed_at: Option<DateTime<Utc>>,
153    pub closed_additional_message: Option<String>,
154    pub closed_course_successor_id: Option<Uuid>,
155    pub chapter_locking_enabled: bool,
156    pub ai_policy: CourseAiPolicy,
157    pub course_material_ai_instructions: Option<bool>,
158}
159
160impl From<Course> for CourseMaterialCourse {
161    fn from(course: Course) -> Self {
162        CourseMaterialCourse {
163            id: course.id,
164            slug: course.slug,
165            name: course.name,
166            description: course.description,
167            organization_id: course.organization_id,
168            language_code: course.language_code,
169            copied_from: course.copied_from,
170            content_search_language: course.content_search_language,
171            course_language_group_id: course.course_language_group_id,
172            is_draft: course.is_draft,
173            is_test_mode: course.is_test_mode,
174            is_unlisted: course.is_unlisted,
175            base_module_completion_requires_n_submodule_completions: course
176                .base_module_completion_requires_n_submodule_completions,
177            is_joinable_by_code_only: course.is_joinable_by_code_only,
178            ask_marketing_consent: course.ask_marketing_consent,
179            closed_at: course.closed_at,
180            closed_additional_message: course.closed_additional_message,
181            closed_course_successor_id: course.closed_course_successor_id,
182            chapter_locking_enabled: course.chapter_locking_enabled,
183            ai_policy: course.ai_policy,
184            course_material_ai_instructions: course.course_material_ai_instructions,
185        }
186    }
187}
188
189/** All the necessary info that can be used to switch the user's browser to a different language version of the course. */
190#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
191
192pub struct CourseLanguageVersionNavigationInfo {
193    pub course_language_group_id: Uuid,
194    pub course_id: Uuid,
195    pub language_code: String,
196    pub course_slug: String,
197    pub page_path: String,
198    pub is_draft: bool,
199    pub current_page_unavailable_in_this_language: bool,
200}
201
202impl CourseLanguageVersionNavigationInfo {
203    /// Creates a new `CourseLanguageVersionNavigationInfo` from a course and page language group navigation info.
204    pub fn from_course_and_page_info(
205        course: &Course,
206        page_info: Option<&crate::page_language_groups::PageLanguageGroupNavigationInfo>,
207    ) -> Self {
208        Self {
209            course_language_group_id: course.course_language_group_id,
210            course_id: course.id,
211            language_code: course.language_code.clone(),
212            course_slug: course.slug.clone(),
213            page_path: page_info
214                .map(|p| p.page_path.clone())
215                .unwrap_or_else(|| "/".to_string()),
216            is_draft: course.is_draft,
217            current_page_unavailable_in_this_language: page_info.is_none(),
218        }
219    }
220}
221
222#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
223
224pub struct CourseBreadcrumbInfo {
225    pub course_id: Uuid,
226    pub course_name: String,
227    pub course_slug: String,
228    pub organization_slug: String,
229    pub organization_name: String,
230}
231
232/// Represents the subset of page fields that are required to create a new course.
233#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
234
235pub struct NewCourse {
236    pub name: String,
237    pub slug: String,
238    pub organization_id: Uuid,
239    pub language_code: String,
240    /// Name of the teacher who is responsible for the course. Must be a valid name.
241    pub teacher_in_charge_name: String,
242    /// Email of the teacher who is responsible for the course. Must be a valid email.
243    pub teacher_in_charge_email: String,
244    pub description: String,
245    pub is_draft: bool,
246    pub is_test_mode: bool,
247    pub is_unlisted: bool,
248    /// If true, copies all user permissions from the original course to the new one.
249    pub copy_user_permissions: bool,
250    pub is_joinable_by_code_only: bool,
251    pub join_code: Option<String>,
252    pub ask_marketing_consent: bool,
253    pub flagged_answers_threshold: Option<i32>,
254    pub can_add_chatbot: bool,
255}
256
257#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
258
259pub struct CourseAuditingData {
260    pub id: Uuid,
261    pub name: String,
262    pub slug: String,
263    pub created_at: DateTime<Utc>,
264    pub updated_at: DateTime<Utc>,
265    pub description: Option<String>,
266    pub is_draft: bool,
267    pub is_test_mode: bool,
268    pub is_unlisted: bool,
269    pub is_joinable_by_code_only: bool,
270    pub closed_at: Option<DateTime<Utc>>,
271    pub closed_additional_message: Option<String>,
272    pub closed_course_successor_id: Option<Uuid>,
273    pub organization_id: Uuid,
274    pub organization_name: String,
275    pub organization_slug: String,
276    pub prerequisites: Vec<EditCoursePrerequisite>,
277    pub audiences: Vec<EditCourseAudience>,
278    pub modules: Vec<CourseModule>,
279}
280
281#[derive(Debug, Deserialize, ToSchema)]
282
283pub struct CourseAuditingDataUpdate {
284    pub description: Option<String>,
285    pub is_draft: bool,
286    pub is_test_mode: bool,
287    pub is_unlisted: bool,
288    pub is_joinable_by_code_only: bool,
289    pub closed_at: Option<DateTime<Utc>>,
290    pub closed_additional_message: Option<String>,
291    pub closed_course_successor_id: Option<Uuid>,
292    pub prerequisites: Vec<EditCoursePrerequisite>,
293    pub audiences: Vec<EditCourseAudience>,
294    pub modules: Vec<CourseAuditingModuleUpdate>,
295}
296
297pub async fn insert(
298    conn: &mut PgConnection,
299    app_config: &ApplicationConfiguration,
300    pkey_policy: PKeyPolicy<Uuid>,
301    course_language_group_id: Uuid,
302    new_course: &NewCourse,
303) -> ModelResult<Uuid> {
304    let title = non_empty_trimmed(&new_course.name);
305    let name = title.unwrap_or(&new_course.name);
306    let description = non_empty_trimmed(&new_course.description);
307    let embeddings = if app_config.disable_embedding_vector_creation_when_seeding {
308        None
309    } else {
310        let texts = title.into_iter().chain(description).map(str::to_string);
311        let embeddings = TextEmbeddings::create(app_config, texts.collect()).await?;
312        Some((
313            embeddings.get_or_create_one(app_config, title).await?,
314            embeddings
315                .get_or_create_one(app_config, description)
316                .await?,
317        ))
318    };
319
320    let mut tx = conn.begin().await?;
321
322    let res = sqlx::query!(
323        "
324INSERT INTO courses(
325    id,
326    name,
327    description,
328    slug,
329    organization_id,
330    language_code,
331    course_language_group_id,
332    is_draft,
333    is_test_mode,
334    is_joinable_by_code_only,
335    join_code,
336    can_add_chatbot
337  )
338VALUES(
339    $1,
340    $2,
341    $3,
342    $4,
343    $5,
344    $6,
345    $7,
346    $8,
347    $9,
348    $10,
349    $11,
350    $12
351  )
352RETURNING id
353        ",
354        pkey_policy.into_uuid(),
355        name,
356        description,
357        new_course.slug,
358        new_course.organization_id,
359        new_course.language_code,
360        course_language_group_id,
361        new_course.is_draft,
362        new_course.is_test_mode,
363        new_course.is_joinable_by_code_only,
364        new_course.join_code,
365        new_course.can_add_chatbot,
366    )
367    .fetch_one(&mut *tx)
368    .await?;
369    if let Some((title_embedding, description_embedding)) = embeddings {
370        update_course_embeddings(&mut tx, res.id, title_embedding, description_embedding).await?
371    }
372    tx.commit().await?;
373    Ok(res.id)
374}
375
376#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
377
378pub struct CourseStructure {
379    pub course: Course,
380    pub pages: Vec<Page>,
381    pub chapters: Vec<Chapter>,
382    pub modules: Vec<CourseModule>,
383}
384
385pub async fn all_courses(conn: &mut PgConnection) -> ModelResult<Vec<Course>> {
386    let courses = sqlx::query_as!(
387        Course,
388        r#"
389SELECT *
390FROM courses
391WHERE deleted_at IS NULL;
392"#
393    )
394    .fetch_all(conn)
395    .await?;
396    Ok(courses)
397}
398
399pub async fn all_courses_for_auditing(
400    conn: &mut PgConnection,
401) -> ModelResult<Vec<CourseAuditingData>> {
402    let courses_data = sqlx::query!(
403        r#"
404SELECT c.id,
405  c.name,
406  c.slug,
407  c.created_at,
408  c.updated_at,
409  c.organization_id,
410  c.description,
411  c.is_draft,
412  c.is_test_mode,
413  c.is_unlisted,
414  c.is_joinable_by_code_only,
415  c.closed_at,
416  c.closed_additional_message,
417  c.closed_course_successor_id,
418  o.name AS organization_name,
419  o.slug AS organization_slug
420FROM courses c
421  JOIN organizations o ON o.id = c.organization_id
422WHERE c.deleted_at IS NULL
423  AND o.deleted_at IS NULL
424GROUP BY c.id,
425  c.name,
426  o.name,
427  o.slug
428"#
429    )
430    .fetch_all(&mut *conn)
431    .await?;
432
433    let modules_data = crate::course_modules::get_all_modules(conn).await?;
434
435    let prerequisites_data =
436        crate::course_prerequisites::get_all_edit_course_prerequisites(conn).await?;
437
438    let audiences_data = crate::course_audiences::get_all_edit_course_audiences(conn).await?;
439
440    let mut modules_by_course_id: HashMap<Uuid, Vec<CourseModule>> = HashMap::new();
441    for module in modules_data {
442        modules_by_course_id
443            .entry(module.course_id)
444            .or_default()
445            .push(module);
446    }
447
448    for course_modules in modules_by_course_id.values_mut() {
449        course_modules.sort_by_key(|c| c.order_number);
450    }
451
452    let mut prerequisites_by_course_id: HashMap<Uuid, Vec<EditCoursePrerequisite>> = HashMap::new();
453    for prerequisite in prerequisites_data {
454        prerequisites_by_course_id
455            .entry(prerequisite.course_id)
456            .or_default()
457            .push(prerequisite);
458    }
459
460    let mut audiences_by_course_id: HashMap<Uuid, Vec<EditCourseAudience>> = HashMap::new();
461    for audience in audiences_data {
462        audiences_by_course_id
463            .entry(audience.course_id)
464            .or_default()
465            .push(audience);
466    }
467
468    let data: Vec<CourseAuditingData> = courses_data
469        .into_iter()
470        .map(|c| CourseAuditingData {
471            id: c.id,
472            name: c.name,
473            slug: c.slug,
474            created_at: c.created_at,
475            updated_at: c.updated_at,
476            organization_id: c.organization_id,
477            description: c.description,
478            is_draft: c.is_draft,
479            is_test_mode: c.is_test_mode,
480            is_unlisted: c.is_unlisted,
481            is_joinable_by_code_only: c.is_joinable_by_code_only,
482            closed_at: c.closed_at,
483            closed_additional_message: c.closed_additional_message,
484            closed_course_successor_id: c.closed_course_successor_id,
485            organization_name: c.organization_name,
486            organization_slug: c.organization_slug,
487            modules: modules_by_course_id.remove(&c.id).unwrap_or_default(),
488            prerequisites: prerequisites_by_course_id.remove(&c.id).unwrap_or_default(),
489            audiences: audiences_by_course_id.remove(&c.id).unwrap_or_default(),
490        })
491        .collect();
492
493    Ok(data)
494}
495
496pub async fn course_auditing_data_by_id(
497    conn: &mut PgConnection,
498    course_id: Uuid,
499) -> ModelResult<CourseAuditingData> {
500    let course_data = sqlx::query!(
501        r#"
502SELECT c.id,
503  c.name,
504  c.slug,
505  c.created_at,
506  c.updated_at,
507  c.organization_id,
508  c.description,
509  c.is_draft,
510  c.is_test_mode,
511  c.is_unlisted,
512  c.is_joinable_by_code_only,
513  c.closed_at,
514  c.closed_additional_message,
515  c.closed_course_successor_id,
516  o.name AS organization_name,
517  o.slug AS organization_slug
518FROM courses c
519  JOIN organizations o ON o.id = c.organization_id
520WHERE c.id = $1
521  AND c.deleted_at IS NULL
522  AND o.deleted_at IS NULL
523GROUP BY c.id,
524  c.name,
525  o.name,
526  o.slug
527"#,
528        course_id
529    )
530    .fetch_one(&mut *conn)
531    .await?;
532
533    let mut modules_data = crate::course_modules::get_by_course_id(conn, course_id).await?;
534
535    modules_data.sort_by_key(|c| c.order_number);
536
537    let prerequisites_data =
538        crate::course_prerequisites::get_edit_course_prerequisites_by_course_id(conn, course_id)
539            .await?;
540
541    let audiences_data =
542        crate::course_audiences::get_edit_course_audiences_by_course_id(conn, course_id).await?;
543
544    let data: CourseAuditingData = CourseAuditingData {
545        id: course_data.id,
546        name: course_data.name,
547        slug: course_data.slug,
548        created_at: course_data.created_at,
549        updated_at: course_data.updated_at,
550        organization_id: course_data.organization_id,
551        description: course_data.description,
552        is_draft: course_data.is_draft,
553        is_test_mode: course_data.is_test_mode,
554        is_unlisted: course_data.is_unlisted,
555        is_joinable_by_code_only: course_data.is_joinable_by_code_only,
556        closed_at: course_data.closed_at,
557        closed_additional_message: course_data.closed_additional_message,
558        closed_course_successor_id: course_data.closed_course_successor_id,
559        organization_name: course_data.organization_name,
560        organization_slug: course_data.organization_slug,
561        modules: modules_data,
562        prerequisites: prerequisites_data,
563        audiences: audiences_data,
564    };
565
566    Ok(data)
567}
568
569/// Saves the auditing view's changes and records the course settings and modules it changed in
570/// their history. Modules of other courses in the update are ignored.
571pub async fn update_course_auditing_data(
572    conn: &mut PgConnection,
573    app_config: &ApplicationConfiguration,
574    course_id: Uuid,
575    data_update: CourseAuditingDataUpdate,
576    author_user_id: Uuid,
577) -> ModelResult<()> {
578    // destructure so new fields cause a compilation error here
579    let CourseAuditingDataUpdate {
580        description,
581        is_draft,
582        is_test_mode,
583        is_unlisted,
584        is_joinable_by_code_only,
585        closed_at,
586        closed_additional_message,
587        closed_course_successor_id,
588        prerequisites,
589        audiences,
590        modules,
591    } = data_update;
592    let author = HistoryAuthor::user(author_user_id);
593    let audiences = audience_snapshots(audiences);
594    let edit = |live: &CourseSettingsSnapshot| CourseSettingsSnapshot {
595        description: description.clone(),
596        is_draft,
597        is_test_mode,
598        is_unlisted,
599        is_joinable_by_code_only,
600        closed_at,
601        closed_additional_message: closed_additional_message.clone(),
602        closed_course_successor_id,
603        audiences: audiences.clone(),
604        ..live.clone()
605    };
606    let embeddings =
607        embed_changed_metadata(conn, app_config, course_id, &edit, &prerequisites).await?;
608    let mut tx = conn.begin().await?;
609    let live = load_settings(&mut tx, course_id).await?;
610    write_settings(&mut tx, app_config, &edit(&live), &live, false, &embeddings).await?;
611    write_prerequisites(&mut tx, app_config, course_id, prerequisites, &embeddings).await?;
612    entity_history::record_current(
613        &mut tx,
614        ContentEntityType::CourseSettings,
615        course_id,
616        author,
617    )
618    .await?;
619
620    for module_id in
621        crate::course_modules::update_auditing_fields(&mut tx, course_id, modules).await?
622    {
623        entity_history::record_current(&mut tx, ContentEntityType::Module, module_id, author)
624            .await?;
625    }
626
627    tx.commit().await?;
628    Ok(())
629}
630
631pub async fn all_courses_user_enrolled_to(
632    conn: &mut PgConnection,
633    user_id: Uuid,
634) -> ModelResult<Vec<Course>> {
635    let courses = sqlx::query_as!(
636        Course,
637        r#"
638SELECT *
639FROM courses
640WHERE courses.deleted_at IS NULL
641  AND id IN (
642    SELECT current_course_id
643    FROM user_course_settings
644    WHERE deleted_at IS NULL
645      AND user_id = $1
646  )
647"#,
648        user_id
649    )
650    .fetch_all(conn)
651    .await?;
652    Ok(courses)
653}
654
655/// A course with its organization's name, as listed to a native client.
656#[derive(Debug)]
657pub struct CourseWithOrganizationName {
658    pub id: Uuid,
659    pub slug: String,
660    pub name: String,
661    pub description: Option<String>,
662    pub ai_policy: CourseAiPolicy,
663    pub organization_name: String,
664}
665
666/// Courses the user is enrolled on, through any of their instances, that contain at least one
667/// task of any of the given exercise types; each course once, ordered by name. An empty
668/// `exercise_types` matches nothing.
669pub async fn get_enrolled_courses_with_exercise_types(
670    conn: &mut PgConnection,
671    user_id: Uuid,
672    exercise_types: &[String],
673) -> ModelResult<Vec<CourseWithOrganizationName>> {
674    let courses = sqlx::query_as!(
675        CourseWithOrganizationName,
676        r#"
677SELECT c.id,
678  c.slug,
679  c.name,
680  c.description,
681  c.ai_policy AS "ai_policy: CourseAiPolicy",
682  o.name AS organization_name
683FROM courses AS c
684  JOIN organizations AS o ON o.id = c.organization_id
685WHERE c.deleted_at IS NULL
686  AND o.deleted_at IS NULL
687  AND EXISTS (
688    SELECT 1
689    FROM course_instance_enrollments AS cie
690      JOIN course_instances AS ci ON ci.id = cie.course_instance_id
691    WHERE cie.user_id = $1
692      AND ci.course_id = c.id
693      AND cie.deleted_at IS NULL
694      AND ci.deleted_at IS NULL
695  )
696  AND EXISTS (
697    SELECT 1
698    FROM exercises AS e
699      JOIN exercise_slides AS es ON es.exercise_id = e.id
700      JOIN exercise_tasks AS et ON et.exercise_slide_id = es.id
701    WHERE e.course_id = c.id
702      AND et.exercise_type = ANY($2)
703      AND e.deleted_at IS NULL
704      AND es.deleted_at IS NULL
705      AND et.deleted_at IS NULL
706  )
707ORDER BY c.name,
708  c.id
709"#,
710        user_id,
711        exercise_types,
712    )
713    .fetch_all(conn)
714    .await?;
715    Ok(courses)
716}
717
718pub async fn all_courses_with_roles_for_user(
719    conn: &mut PgConnection,
720    user_id: Uuid,
721) -> ModelResult<Vec<Course>> {
722    let courses = sqlx::query_as!(
723        Course,
724        r#"
725SELECT *
726FROM courses
727WHERE courses.deleted_at IS NULL
728  AND (
729    id IN (
730      SELECT course_id
731      FROM roles
732      WHERE deleted_at IS NULL
733        AND user_id = $1
734        AND course_id IS NOT NULL
735    )
736    OR (
737      id IN (
738        SELECT ci.course_id
739        FROM course_instances ci
740          JOIN ROLES r ON r.course_instance_id = ci.id
741        WHERE r.user_id = $1
742          AND r.deleted_at IS NULL
743          AND ci.deleted_at IS NULL
744      )
745    )
746  ) "#,
747        user_id
748    )
749    .fetch_all(conn)
750    .await?;
751    Ok(courses)
752}
753
754pub async fn get_all_language_versions_of_course(
755    conn: &mut PgConnection,
756    course: &Course,
757) -> ModelResult<Vec<Course>> {
758    let courses = sqlx::query_as!(
759        Course,
760        r#"
761SELECT *
762FROM courses
763WHERE course_language_group_id = $1
764AND deleted_at IS NULL
765        "#,
766        course.course_language_group_id,
767    )
768    .fetch_all(conn)
769    .await?;
770    Ok(courses)
771}
772
773/// The courses of the course language groups: [`get_all_language_versions_of_course`] for many
774/// groups at once.
775pub async fn get_by_course_language_group_ids(
776    conn: &mut PgConnection,
777    course_language_group_ids: &[Uuid],
778) -> ModelResult<Vec<Course>> {
779    let courses = sqlx::query_as!(
780        Course,
781        r#"
782SELECT *
783FROM courses
784WHERE course_language_group_id = ANY($1)
785  AND deleted_at IS NULL
786        "#,
787        course_language_group_ids,
788    )
789    .fetch_all(conn)
790    .await?;
791    Ok(courses)
792}
793
794pub async fn get_active_courses_for_organization(
795    conn: &mut PgConnection,
796    organization_id: Uuid,
797    pagination: Pagination,
798) -> ModelResult<Vec<Course>> {
799    let course_instances = sqlx::query_as!(
800        Course,
801        r#"
802SELECT
803    DISTINCT(c.id),
804    c.name,
805    c.created_at,
806    c.updated_at,
807    c.organization_id,
808    c.deleted_at,
809    c.slug,
810    c.content_search_language::text,
811    c.language_code,
812    c.copied_from,
813    c.course_language_group_id,
814    c.description,
815    c.is_draft,
816    c.is_test_mode,
817    c.base_module_completion_requires_n_submodule_completions,
818    c.can_add_chatbot,
819    c.is_unlisted,
820    c.is_joinable_by_code_only,
821    c.join_code,
822    c.ask_marketing_consent,
823    c.flagged_answers_threshold,
824    c.flagged_answers_skip_manual_review_and_allow_retry,
825    c.closed_at,
826    c.closed_additional_message,
827    c.closed_course_successor_id,
828    c.chapter_locking_enabled,
829    c.cheater_detection_enabled,
830    c.ai_policy,
831    c.course_material_ai_instructions
832FROM courses as c
833    LEFT JOIN course_instances as ci on c.id = ci.course_id
834WHERE
835    c.organization_id = $1 AND
836    ci.starts_at < NOW() AND ci.ends_at > NOW() AND
837    c.deleted_at IS NULL AND ci.deleted_at IS NULL
838    LIMIT $2 OFFSET $3;
839        "#,
840        organization_id,
841        pagination.limit(),
842        pagination.offset()
843    )
844    .fetch_all(conn)
845    .await?;
846    Ok(course_instances)
847}
848
849pub async fn get_active_courses_for_organization_count(
850    conn: &mut PgConnection,
851    organization_id: Uuid,
852) -> ModelResult<CourseCount> {
853    let result = sqlx::query!(
854        r#"
855SELECT
856    COUNT(DISTINCT c.id) as count
857FROM courses as c
858    LEFT JOIN course_instances as ci on c.id = ci.course_id
859WHERE
860    c.organization_id = $1 AND
861    ci.starts_at < NOW() AND ci.ends_at > NOW() AND
862    c.deleted_at IS NULL AND ci.deleted_at IS NULL;
863        "#,
864        organization_id
865    )
866    .fetch_one(conn)
867    .await?;
868    Ok(CourseCount {
869        count: result.count.unwrap_or_default().try_into()?,
870    })
871}
872
873pub async fn get_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
874    let course = sqlx::query_as!(
875        Course,
876        r#"
877SELECT *
878FROM courses
879WHERE id = $1
880  AND deleted_at IS NULL;
881    "#,
882        course_id
883    )
884    .fetch_one(conn)
885    .await?;
886    Ok(course)
887}
888
889pub async fn get_by_id_and_join_code(
890    conn: &mut PgConnection,
891    course_id: Uuid,
892    join_code: &str,
893) -> ModelResult<Course> {
894    let course = sqlx::query_as!(
895        Course,
896        r#"
897SELECT *
898FROM courses
899WHERE id = $1
900  AND join_code = $2
901  AND deleted_at IS NULL;
902    "#,
903        course_id,
904        join_code,
905    )
906    .fetch_one(conn)
907    .await?;
908    Ok(course)
909}
910
911pub async fn get_course_breadcrumb_info(
912    conn: &mut PgConnection,
913    course_id: Uuid,
914) -> ModelResult<CourseBreadcrumbInfo> {
915    let res = sqlx::query_as!(
916        CourseBreadcrumbInfo,
917        r#"
918SELECT courses.id as course_id,
919  courses.name as course_name,
920  courses.slug as course_slug,
921  organizations.slug as organization_slug,
922  organizations.name as organization_name
923FROM courses
924  JOIN organizations ON (courses.organization_id = organizations.id)
925WHERE courses.id = $1
926  AND courses.deleted_at IS NULL;
927    "#,
928        course_id
929    )
930    .fetch_one(conn)
931    .await?;
932    Ok(res)
933}
934
935pub async fn get_nondeleted_course_id_by_slug(
936    conn: &mut PgConnection,
937    slug: &str,
938) -> ModelResult<CourseContextData> {
939    let data = sqlx::query_as!(
940        CourseContextData,
941        "SELECT id, is_test_mode FROM courses WHERE slug = $1 AND deleted_at IS NULL",
942        slug
943    )
944    .fetch_one(conn)
945    .await?;
946    Ok(data)
947}
948
949/// None when no active course has this slug.
950pub async fn get_active_course_id_by_slug(
951    conn: &mut PgConnection,
952    slug: &str,
953) -> ModelResult<Option<Uuid>> {
954    let id = sqlx::query_scalar!(
955        "SELECT id FROM courses WHERE slug = $1 AND deleted_at IS NULL",
956        slug
957    )
958    .fetch_optional(conn)
959    .await?;
960    Ok(id)
961}
962
963pub async fn get_organization_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
964    let organization_id = sqlx::query!("SELECT organization_id FROM courses WHERE id = $1", id)
965        .fetch_one(conn)
966        .await?
967        .organization_id;
968    Ok(organization_id)
969}
970
971/// Gets full course structure including all the pages.
972pub async fn get_course_structure(
973    conn: &mut PgConnection,
974    course_id: Uuid,
975    file_store: &dyn FileStore,
976    app_conf: &ApplicationConfiguration,
977) -> ModelResult<CourseStructure> {
978    let course = get_course(conn, course_id).await?;
979    let pages = get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
980    let chapters = get_course_chapters(conn, course_id)
981        .await?
982        .iter()
983        .map(|chapter| Chapter::from_database_chapter(chapter, file_store, app_conf))
984        .collect();
985    let modules = crate::course_modules::get_by_course_id(conn, course_id).await?;
986    Ok(CourseStructure {
987        course,
988        pages,
989        chapters,
990        modules,
991    })
992}
993
994pub async fn organization_courses_visible_to_user_paginated(
995    conn: &mut PgConnection,
996    organization_id: Uuid,
997    user: Option<Uuid>,
998    pagination: Pagination,
999) -> ModelResult<Vec<Course>> {
1000    let courses = sqlx::query_as!(
1001        Course,
1002        r#"
1003SELECT courses.id,
1004  courses.name,
1005  courses.created_at,
1006  courses.updated_at,
1007  courses.organization_id,
1008  courses.deleted_at,
1009  courses.slug,
1010  courses.content_search_language::text,
1011  courses.language_code,
1012  courses.copied_from,
1013  courses.course_language_group_id,
1014  courses.description,
1015  courses.is_draft,
1016  courses.is_test_mode,
1017  base_module_completion_requires_n_submodule_completions,
1018  can_add_chatbot,
1019  courses.is_unlisted,
1020  courses.is_joinable_by_code_only,
1021  courses.join_code,
1022  courses.ask_marketing_consent,
1023  courses.flagged_answers_threshold,
1024  courses.flagged_answers_skip_manual_review_and_allow_retry,
1025  courses.closed_at,
1026  courses.closed_additional_message,
1027  courses.closed_course_successor_id,
1028  courses.chapter_locking_enabled,
1029  courses.cheater_detection_enabled,
1030  courses.ai_policy,
1031  courses.course_material_ai_instructions
1032FROM courses
1033WHERE courses.organization_id = $1
1034  AND (
1035    (
1036      courses.is_draft IS FALSE
1037      AND courses.is_unlisted IS FALSE
1038    )
1039    OR EXISTS (
1040      SELECT id
1041      FROM roles
1042      WHERE user_id = $2
1043        AND (
1044          course_id = courses.id
1045          OR roles.organization_id = courses.organization_id
1046          OR roles.is_global IS TRUE
1047        )
1048    )
1049  )
1050  AND courses.deleted_at IS NULL
1051ORDER BY courses.name
1052LIMIT $3 OFFSET $4;
1053"#,
1054        organization_id,
1055        user,
1056        pagination.limit(),
1057        pagination.offset()
1058    )
1059    .fetch_all(conn)
1060    .await?;
1061    Ok(courses)
1062}
1063
1064pub async fn organization_course_count(
1065    conn: &mut PgConnection,
1066    organization_id: Uuid,
1067) -> ModelResult<CourseCount> {
1068    let course_count = sqlx::query!(
1069        r#"
1070SELECT
1071    COUNT(DISTINCT id) as count
1072FROM courses
1073WHERE organization_id = $1
1074    AND deleted_at IS NULL;
1075        "#,
1076        organization_id,
1077    )
1078    .fetch_one(conn)
1079    .await?;
1080    Ok(CourseCount {
1081        count: course_count.count.unwrap_or_default().try_into()?,
1082    })
1083}
1084// Represents the subset of page fields that one is allowed to update in a course
1085#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default, ToSchema)]
1086
1087pub struct CourseUpdate {
1088    pub name: String,
1089    pub description: Option<String>,
1090    pub is_draft: bool,
1091    pub is_test_mode: bool,
1092    pub can_add_chatbot: bool,
1093    pub is_unlisted: bool,
1094    pub is_joinable_by_code_only: bool,
1095    pub ask_marketing_consent: bool,
1096    pub flagged_answers_threshold: i32,
1097    pub flagged_answers_skip_manual_review_and_allow_retry: bool,
1098    pub closed_at: Option<DateTime<Utc>>,
1099    pub closed_additional_message: Option<String>,
1100    pub closed_course_successor_id: Option<Uuid>,
1101    pub chapter_locking_enabled: bool,
1102    pub ai_policy: CourseAiPolicy,
1103    pub course_material_ai_instructions: Option<bool>,
1104}
1105
1106/// Lets the rest of the transaction swap chapter numbers, module and page order numbers, page
1107/// paths and citation keys, and point a chapter at a front page it writes later; these
1108/// constraints are checked at commit.
1109pub(crate) async fn defer_structure_constraints(conn: &mut PgConnection) -> ModelResult<()> {
1110    sqlx::query!(
1111        "SET CONSTRAINTS unique_chapters_chapter_number_course_id_when_not_deleted,
1112  course_modules_order_number_uniqueness,
1113  pages_order_number_uniqueness,
1114  unique_pages_url_path_course_id_when_not_deleted,
1115  citation_key_uniqueness,
1116  chapters_front_page_id_fkey DEFERRED"
1117    )
1118    .execute(conn)
1119    .await?;
1120    Ok(())
1121}
1122
1123impl From<&Course> for CourseUpdate {
1124    /// The course's current values, to change some of them and save with [`update_course`].
1125    fn from(course: &Course) -> Self {
1126        Self {
1127            name: course.name.clone(),
1128            description: course.description.clone(),
1129            is_draft: course.is_draft,
1130            is_test_mode: course.is_test_mode,
1131            can_add_chatbot: course.can_add_chatbot,
1132            is_unlisted: course.is_unlisted,
1133            is_joinable_by_code_only: course.is_joinable_by_code_only,
1134            ask_marketing_consent: course.ask_marketing_consent,
1135            flagged_answers_threshold: course.flagged_answers_threshold.unwrap_or(0),
1136            flagged_answers_skip_manual_review_and_allow_retry: course
1137                .flagged_answers_skip_manual_review_and_allow_retry,
1138            closed_at: course.closed_at,
1139            closed_additional_message: course.closed_additional_message.clone(),
1140            closed_course_successor_id: course.closed_course_successor_id,
1141            chapter_locking_enabled: course.chapter_locking_enabled,
1142            ai_policy: course.ai_policy,
1143            course_material_ai_instructions: course.course_material_ai_instructions,
1144        }
1145    }
1146}
1147
1148/// Saves the course's editable fields and records the result in its history. Errors with
1149/// `PreconditionFailed` if the update changes `can_add_chatbot` and `can_change_chatbot` is false.
1150pub async fn update_course(
1151    conn: &mut PgConnection,
1152    app_config: &ApplicationConfiguration,
1153    course_id: Uuid,
1154    course_update: CourseUpdate,
1155    author_user_id: Uuid,
1156    can_change_chatbot: bool,
1157) -> ModelResult<Course> {
1158    // destructure so new fields cause a compilation error here
1159    let CourseUpdate {
1160        name,
1161        description,
1162        is_draft,
1163        is_test_mode,
1164        can_add_chatbot,
1165        is_unlisted,
1166        is_joinable_by_code_only,
1167        ask_marketing_consent,
1168        flagged_answers_threshold,
1169        flagged_answers_skip_manual_review_and_allow_retry,
1170        closed_at,
1171        closed_additional_message,
1172        closed_course_successor_id,
1173        chapter_locking_enabled,
1174        ai_policy,
1175        course_material_ai_instructions,
1176    } = course_update;
1177    let edit = move |live: &CourseSettingsSnapshot| CourseSettingsSnapshot {
1178        name: name.clone(),
1179        description: description.clone(),
1180        is_draft,
1181        is_test_mode,
1182        can_add_chatbot,
1183        is_unlisted,
1184        is_joinable_by_code_only,
1185        ask_marketing_consent,
1186        flagged_answers_threshold: Some(flagged_answers_threshold),
1187        flagged_answers_skip_manual_review_and_allow_retry,
1188        closed_at,
1189        closed_additional_message: closed_additional_message.clone(),
1190        closed_course_successor_id,
1191        chapter_locking_enabled,
1192        ai_policy,
1193        course_material_ai_instructions,
1194        ..live.clone()
1195    };
1196    let live = load_settings(conn, course_id).await?;
1197    let embeddings =
1198        TextEmbeddings::create(app_config, changed_settings_texts(&edit(&live), &live)).await?;
1199    entity_history::save_recording(
1200        conn,
1201        ContentEntityType::CourseSettings,
1202        course_id,
1203        HistoryAuthor::user(author_user_id),
1204        async move |tx| {
1205            let live = load_settings(&mut *tx, course_id).await?;
1206            write_settings(
1207                tx,
1208                app_config,
1209                &edit(&live),
1210                &live,
1211                can_change_chatbot,
1212                &embeddings,
1213            )
1214            .await
1215        },
1216    )
1217    .await
1218}
1219
1220async fn load_settings(
1221    conn: &mut PgConnection,
1222    course_id: Uuid,
1223) -> ModelResult<CourseSettingsSnapshot> {
1224    let course = get_course(conn, course_id).await?;
1225    entity_snapshots::course_settings_of(conn, &course).await
1226}
1227
1228/// Writes the course settings of the snapshot without recording history: the course row in one
1229/// write, and the audiences that differ from `live`, the current settings. Embeddings of the
1230/// changed name, description and audiences come from `embeddings` where it has them. Errors with
1231/// `PreconditionFailed` if the snapshot is of another course, or if it changes `can_add_chatbot`
1232/// and `can_change_chatbot` is false.
1233pub async fn write_settings(
1234    conn: &mut PgConnection,
1235    app_config: &ApplicationConfiguration,
1236    settings: &CourseSettingsSnapshot,
1237    live: &CourseSettingsSnapshot,
1238    can_change_chatbot: bool,
1239    embeddings: &TextEmbeddings,
1240) -> ModelResult<Course> {
1241    if settings.course_id != live.course_id {
1242        return Err(model_err!(
1243            PreconditionFailed,
1244            "Course settings snapshot is for another course".to_string()
1245        ));
1246    }
1247    if settings.can_add_chatbot != live.can_add_chatbot && !can_change_chatbot {
1248        return Err(model_err!(
1249            PreconditionFailed,
1250            "Only global admins can change whether the course can have a chatbot".to_string()
1251        ));
1252    }
1253    let course_id = settings.course_id;
1254    let mut tx = conn.begin().await?;
1255    let course = sqlx::query_as!(
1256        Course,
1257        r#"
1258UPDATE courses
1259SET name = $1,
1260  description = $2,
1261  is_draft = $3,
1262  is_test_mode = $4,
1263  can_add_chatbot = $5,
1264  is_unlisted = $6,
1265  is_joinable_by_code_only = $7,
1266  ask_marketing_consent = $8,
1267  flagged_answers_threshold = $9,
1268  flagged_answers_skip_manual_review_and_allow_retry = $10,
1269  closed_at = $11,
1270  closed_additional_message = $12,
1271  closed_course_successor_id = $13,
1272  chapter_locking_enabled = $14,
1273  ai_policy = $15,
1274  course_material_ai_instructions = $16,
1275  base_module_completion_requires_n_submodule_completions = $17
1276WHERE id = $18
1277  AND deleted_at IS NULL
1278RETURNING *
1279    "#,
1280        settings.name,
1281        settings.description,
1282        settings.is_draft,
1283        settings.is_test_mode,
1284        settings.can_add_chatbot,
1285        settings.is_unlisted,
1286        settings.is_joinable_by_code_only,
1287        settings.ask_marketing_consent,
1288        settings.flagged_answers_threshold,
1289        settings.flagged_answers_skip_manual_review_and_allow_retry,
1290        settings.closed_at,
1291        settings.closed_additional_message,
1292        settings.closed_course_successor_id,
1293        settings.chapter_locking_enabled,
1294        settings.ai_policy as CourseAiPolicy,
1295        settings.course_material_ai_instructions,
1296        settings.base_module_completion_requires_n_submodule_completions,
1297        course_id
1298    )
1299    .fetch_one(&mut *tx)
1300    .await?;
1301
1302    let (title, description) = changed_embedded_texts(settings, live);
1303    let title_embedding = embeddings.get_or_create_one(app_config, title).await?;
1304    let description_embedding = embeddings
1305        .get_or_create_one(app_config, description)
1306        .await?;
1307    update_course_embeddings(&mut tx, course_id, title_embedding, description_embedding).await?;
1308
1309    if !live.chapter_locking_enabled && settings.chapter_locking_enabled {
1310        let user_ids: Vec<Uuid> =
1311            crate::user_course_settings::get_all_by_course_id(&mut tx, course_id)
1312                .await?
1313                .into_iter()
1314                .map(|settings| settings.user_id)
1315                .collect();
1316        crate::user_chapter_locking_statuses::init_for_users(&mut tx, course_id, &user_ids).await?;
1317    }
1318
1319    let (changed_ids, changed_audiences) = changed_audiences(&settings.audiences, &live.audiences);
1320    if !changed_audiences.is_empty() {
1321        let embeddings = embeddings
1322            .get_or_create(app_config, &changed_audiences)
1323            .await?;
1324        upsert_course_audiences(
1325            &mut tx,
1326            course_id,
1327            changed_ids,
1328            changed_audiences,
1329            embeddings,
1330        )
1331        .await?;
1332    }
1333    let removed_audience_ids: Vec<Uuid> = live
1334        .audiences
1335        .iter()
1336        .filter(|live| !settings.audiences.iter().any(|a| a.id == live.id))
1337        .map(|live| live.id)
1338        .collect();
1339    crate::course_audiences::delete_batch(&mut tx, removed_audience_ids).await?;
1340
1341    tx.commit().await?;
1342    Ok(course)
1343}
1344
1345/// Enables or disables suspected-cheater detection for a single course. Used by the seed routine to
1346/// turn detection off for seeded courses (which are completed in seconds and would otherwise flag
1347/// every seeded user); production courses keep the on-by-default value set at creation.
1348pub async fn set_cheater_detection_enabled(
1349    conn: &mut PgConnection,
1350    course_id: Uuid,
1351    enabled: bool,
1352) -> ModelResult<()> {
1353    sqlx::query!(
1354        "
1355UPDATE courses
1356SET cheater_detection_enabled = $1
1357WHERE id = $2
1358  AND deleted_at IS NULL
1359        ",
1360        enabled,
1361        course_id,
1362    )
1363    .execute(conn)
1364    .await?;
1365    Ok(())
1366}
1367
1368pub async fn update_course_base_module_completion_count_requirement(
1369    conn: &mut PgConnection,
1370    id: Uuid,
1371    base_module_completion_requires_n_submodule_completions: i32,
1372) -> ModelResult<bool> {
1373    let res = sqlx::query!(
1374        "
1375UPDATE courses
1376SET base_module_completion_requires_n_submodule_completions = $1
1377WHERE id = $2
1378  AND deleted_at IS NULL
1379        ",
1380        base_module_completion_requires_n_submodule_completions,
1381        id,
1382    )
1383    .execute(conn)
1384    .await?;
1385    Ok(res.rows_affected() > 0)
1386}
1387
1388pub async fn delete_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
1389    let deleted = sqlx::query_as!(
1390        Course,
1391        r#"
1392UPDATE courses
1393SET deleted_at = now()
1394WHERE id = $1
1395AND deleted_at IS NULL
1396RETURNING *
1397    "#,
1398        course_id
1399    )
1400    .fetch_one(conn)
1401    .await?;
1402    Ok(deleted)
1403}
1404
1405pub async fn get_course_by_slug(conn: &mut PgConnection, course_slug: &str) -> ModelResult<Course> {
1406    let course = sqlx::query_as!(
1407        Course,
1408        r#"
1409SELECT *
1410FROM courses
1411WHERE slug = $1
1412  AND deleted_at IS NULL
1413"#,
1414        course_slug,
1415    )
1416    .fetch_one(conn)
1417    .await?;
1418    Ok(course)
1419}
1420
1421pub async fn get_cfgname_by_tag(
1422    conn: &mut PgConnection,
1423    ietf_language_tag: String,
1424) -> ModelResult<String> {
1425    let tag = ietf_language_tag
1426        .split('-')
1427        .next()
1428        .unwrap_or_else(|| &ietf_language_tag[..]);
1429
1430    let lang_name = LANGUAGE_TAG_TO_NAME.get(&tag);
1431
1432    let name = sqlx::query!(
1433        "SELECT cfgname::text FROM pg_ts_config WHERE cfgname = $1",
1434        lang_name
1435    )
1436    .fetch_optional(conn)
1437    .await?;
1438
1439    let res = name
1440        .and_then(|n| n.cfgname)
1441        .unwrap_or_else(|| "simple".to_string());
1442
1443    Ok(res)
1444}
1445
1446pub async fn is_draft(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
1447    let res = sqlx::query!(
1448        "
1449SELECT is_draft
1450FROM courses
1451WHERE id = $1
1452",
1453        id
1454    )
1455    .fetch_one(conn)
1456    .await?;
1457    Ok(res.is_draft)
1458}
1459
1460pub async fn is_joinable_by_code_only(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
1461    let res = sqlx::query!(
1462        "
1463SELECT is_joinable_by_code_only
1464FROM courses
1465WHERE id = $1
1466",
1467        id
1468    )
1469    .fetch_one(conn)
1470    .await?;
1471    Ok(res.is_joinable_by_code_only)
1472}
1473
1474pub async fn get_by_ids(conn: &mut PgConnection, course_ids: &[Uuid]) -> ModelResult<Vec<Course>> {
1475    let courses = sqlx::query_as!(
1476        Course,
1477        r#"
1478SELECT *
1479FROM courses
1480WHERE id IN (SELECT * FROM UNNEST($1::uuid[]))
1481  AND deleted_at IS NULL
1482        "#,
1483        course_ids
1484    )
1485    .fetch_all(conn)
1486    .await?;
1487    Ok(courses)
1488}
1489
1490/// Finds courses by slug or (fuzzy) name for the support chatbot's `find_course` tool. Exact
1491/// slug matches rank first, then substring name matches, then trigram similarity to `name`.
1492pub async fn search_courses_by_slug_or_name(
1493    conn: &mut PgConnection,
1494    query: &str,
1495    limit: i64,
1496) -> ModelResult<Vec<Course>> {
1497    let courses = sqlx::query_as!(
1498        Course,
1499        r#"
1500SELECT *
1501FROM courses
1502WHERE deleted_at IS NULL
1503  AND (LOWER(slug) = LOWER($1) OR name ILIKE '%' || $1 || '%' OR similarity(name, $1) > 0.1)
1504ORDER BY
1505  LOWER(slug) = LOWER($1) DESC,
1506  name ILIKE '%' || $1 || '%' DESC,
1507  similarity(name, $1) DESC
1508LIMIT $2
1509        "#,
1510        query,
1511        limit,
1512    )
1513    .fetch_all(conn)
1514    .await?;
1515    Ok(courses)
1516}
1517
1518/// The courses the user holds a role in, directly or through the course's organization, by name.
1519/// Which of them the user may edit is for `headless_lms_authorization` to decide.
1520pub async fn get_by_course_or_organization_role(
1521    conn: &mut PgConnection,
1522    user_id: Uuid,
1523) -> ModelResult<Vec<Course>> {
1524    let courses = sqlx::query_as!(
1525        Course,
1526        r#"
1527SELECT *
1528FROM courses
1529WHERE deleted_at IS NULL
1530  AND (
1531    id IN (
1532      SELECT course_id
1533      FROM roles
1534      WHERE deleted_at IS NULL
1535        AND user_id = $1
1536        AND course_id IS NOT NULL
1537    )
1538    OR organization_id IN (
1539      SELECT organization_id
1540      FROM roles
1541      WHERE deleted_at IS NULL
1542        AND user_id = $1
1543        AND organization_id IS NOT NULL
1544    )
1545  )
1546ORDER BY name COLLATE "C",
1547  id
1548        "#,
1549        user_id
1550    )
1551    .fetch_all(conn)
1552    .await?;
1553    Ok(courses)
1554}
1555
1556pub async fn get_by_organization_id(
1557    conn: &mut PgConnection,
1558    organization_id: Uuid,
1559) -> ModelResult<Vec<Course>> {
1560    let courses = sqlx::query_as!(
1561        Course,
1562        r#"
1563SELECT *
1564FROM courses
1565WHERE organization_id = $1
1566  AND deleted_at IS NULL
1567ORDER BY name
1568        "#,
1569        organization_id
1570    )
1571    .fetch_all(conn)
1572    .await?;
1573    Ok(courses)
1574}
1575
1576pub async fn set_join_code_for_course(
1577    conn: &mut PgConnection,
1578    course_id: Uuid,
1579    join_code: String,
1580) -> ModelResult<()> {
1581    sqlx::query!(
1582        "
1583UPDATE courses
1584SET join_code = $2
1585WHERE id = $1
1586",
1587        course_id,
1588        join_code
1589    )
1590    .execute(conn)
1591    .await?;
1592    Ok(())
1593}
1594
1595pub async fn get_course_with_join_code(
1596    conn: &mut PgConnection,
1597    join_code: String,
1598) -> ModelResult<Course> {
1599    let course = sqlx::query_as!(
1600        Course,
1601        r#"
1602SELECT *
1603FROM courses
1604WHERE join_code = $1
1605  AND deleted_at IS NULL;
1606    "#,
1607        join_code,
1608    )
1609    .fetch_one(conn)
1610    .await?;
1611    Ok(course)
1612}
1613
1614/// Saves the course description, audiences and prerequisites, and records the course settings in
1615/// their history.
1616pub async fn set_metadata(
1617    conn: &mut PgConnection,
1618    app_config: &ApplicationConfiguration,
1619    course_id: Uuid,
1620    course_metadata: CourseMetadataUpdate,
1621    author_user_id: Uuid,
1622) -> ModelResult<CourseMetadata> {
1623    let CourseMetadataUpdate {
1624        course_description,
1625        course_audiences,
1626        course_prerequisites,
1627    } = course_metadata;
1628    let course_audiences = audience_snapshots(course_audiences);
1629    let edit = move |live: &CourseSettingsSnapshot| CourseSettingsSnapshot {
1630        description: course_description.clone(),
1631        audiences: course_audiences.clone(),
1632        ..live.clone()
1633    };
1634    let embeddings =
1635        embed_changed_metadata(conn, app_config, course_id, &edit, &course_prerequisites).await?;
1636    let course = entity_history::save_recording(
1637        conn,
1638        ContentEntityType::CourseSettings,
1639        course_id,
1640        HistoryAuthor::user(author_user_id),
1641        async move |tx| {
1642            let live = load_settings(&mut *tx, course_id).await?;
1643            let course = write_settings(
1644                &mut *tx,
1645                app_config,
1646                &edit(&live),
1647                &live,
1648                false,
1649                &embeddings,
1650            )
1651            .await?;
1652            write_prerequisites(tx, app_config, course_id, course_prerequisites, &embeddings)
1653                .await?;
1654            Ok(course)
1655        },
1656    )
1657    .await?;
1658
1659    Ok(CourseMetadata {
1660        course_description: course.description,
1661        course_audiences: crate::course_audiences::get_by_course_id(conn, course_id).await?,
1662        course_prerequisites: crate::course_prerequisites::get_by_course_id(conn, course_id)
1663            .await?,
1664        course_updated_at: course.updated_at,
1665    })
1666}
1667
1668/// The audiences of an edit, sorted by id as [`CourseSettingsSnapshot::audiences`] are.
1669fn audience_snapshots(audiences: Vec<EditCourseAudience>) -> Vec<CourseAudienceSnapshot> {
1670    let mut audiences: Vec<CourseAudienceSnapshot> = audiences
1671        .into_iter()
1672        .map(|a| CourseAudienceSnapshot {
1673            id: a.id,
1674            audience: a.audience,
1675        })
1676        .collect();
1677    audiences.sort_by_key(|a| a.id);
1678    audiences
1679}
1680
1681/// Saves the course's prerequisites as listed, deleting the ones left out. Only new and changed
1682/// prerequisites get new embeddings, taken from `embeddings` where it has them.
1683async fn write_prerequisites(
1684    conn: &mut PgConnection,
1685    app_config: &ApplicationConfiguration,
1686    course_id: Uuid,
1687    prerequisites: Vec<EditCoursePrerequisite>,
1688    embeddings: &TextEmbeddings,
1689) -> ModelResult<()> {
1690    let old_prerequisites = crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1691    let removed_ids: Vec<Uuid> = old_prerequisites
1692        .iter()
1693        .filter(|old| !prerequisites.iter().any(|p| p.id == old.id))
1694        .map(|old| old.id)
1695        .collect();
1696    let (changed_ids, changed_texts) = changed_prerequisites(&prerequisites, &old_prerequisites);
1697    if !changed_texts.is_empty() {
1698        let embeddings = embeddings.get_or_create(app_config, &changed_texts).await?;
1699        upsert_course_prerequisites(conn, course_id, changed_ids, changed_texts, embeddings)
1700            .await?;
1701    }
1702    crate::course_prerequisites::delete_batch(conn, removed_ids).await?;
1703    Ok(())
1704}
1705
1706/// The audiences of `audiences` that `live` lacks or has with another text.
1707fn changed_audiences(
1708    audiences: &[CourseAudienceSnapshot],
1709    live: &[CourseAudienceSnapshot],
1710) -> (Vec<Uuid>, Vec<String>) {
1711    changed_texts(
1712        audiences,
1713        live,
1714        |a| (a.id, a.audience.as_str()),
1715        |a| (a.id, a.audience.as_str()),
1716    )
1717}
1718
1719/// The prerequisites of `prerequisites` that `old` lacks or has with another text.
1720fn changed_prerequisites(
1721    prerequisites: &[EditCoursePrerequisite],
1722    old: &[CoursePrerequisite],
1723) -> (Vec<Uuid>, Vec<String>) {
1724    changed_texts(
1725        prerequisites,
1726        old,
1727        |p| (p.id, p.prerequisite.as_str()),
1728        |p| (p.id, p.prerequisite.as_str()),
1729    )
1730}
1731
1732/// The ids and texts of `new` that `old` lacks or has with another text.
1733fn changed_texts<New, Old>(
1734    new: &[New],
1735    old: &[Old],
1736    new_text: fn(&New) -> (Uuid, &str),
1737    old_text: fn(&Old) -> (Uuid, &str),
1738) -> (Vec<Uuid>, Vec<String>) {
1739    let old: HashMap<Uuid, &str> = old.iter().map(old_text).collect();
1740    new.iter()
1741        .map(new_text)
1742        .filter(|(id, text)| old.get(id) != Some(text))
1743        .map(|(id, text)| (id, text.to_string()))
1744        .unzip()
1745}
1746
1747/// Embeds the settings texts that `edit` of the live settings and saving `prerequisites` would add
1748/// or change.
1749async fn embed_changed_metadata(
1750    conn: &mut PgConnection,
1751    app_config: &ApplicationConfiguration,
1752    course_id: Uuid,
1753    edit: &impl Fn(&CourseSettingsSnapshot) -> CourseSettingsSnapshot,
1754    prerequisites: &[EditCoursePrerequisite],
1755) -> ModelResult<TextEmbeddings> {
1756    let live = load_settings(conn, course_id).await?;
1757    let old_prerequisites = crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1758    let mut texts = changed_settings_texts(&edit(&live), &live);
1759    texts.extend(changed_prerequisites(prerequisites, &old_prerequisites).1);
1760    TextEmbeddings::create(app_config, texts).await
1761}
1762
1763/// The texts whose embeddings [`write_settings`] updates when writing `settings` over `live`.
1764pub(crate) fn changed_settings_texts(
1765    settings: &CourseSettingsSnapshot,
1766    live: &CourseSettingsSnapshot,
1767) -> Vec<String> {
1768    let (title, description) = changed_embedded_texts(settings, live);
1769    let mut texts: Vec<String> = title
1770        .into_iter()
1771        .chain(description)
1772        .map(str::to_string)
1773        .collect();
1774    texts.extend(changed_audiences(&settings.audiences, &live.audiences).1);
1775    texts
1776}
1777
1778/// Every text of `settings` that has an embedding, for settings with no earlier version.
1779pub(crate) fn settings_texts(settings: &CourseSettingsSnapshot) -> Vec<String> {
1780    std::iter::once(settings.name.as_str())
1781        .chain(settings.description.as_deref().filter(|d| !d.is_empty()))
1782        .chain(settings.audiences.iter().map(|a| a.audience.as_str()))
1783        .map(str::to_string)
1784        .collect()
1785}
1786
1787/// The name and description of `settings` whose course embeddings need updating.
1788fn changed_embedded_texts<'a>(
1789    settings: &'a CourseSettingsSnapshot,
1790    live: &CourseSettingsSnapshot,
1791) -> (Option<&'a str>, Option<&'a str>) {
1792    let title = (settings.name != live.name).then_some(settings.name.as_str());
1793    let description = settings
1794        .description
1795        .as_deref()
1796        .filter(|description| settings.description != live.description && !description.is_empty());
1797    (title, description)
1798}
1799
1800/// Embeddings of course name, description, audience and prerequisite texts, made before a save's
1801/// transaction so that the save doesn't wait for the embedding API while holding row locks. Texts
1802/// it lacks, such as ones changed in between, are embedded during the save.
1803#[derive(Debug, Default)]
1804pub struct TextEmbeddings(HashMap<String, Vec<f32>>);
1805
1806impl TextEmbeddings {
1807    /// Embeds `texts`; calls the embedding API only if there are any.
1808    pub async fn create(
1809        app_config: &ApplicationConfiguration,
1810        texts: Vec<String>,
1811    ) -> ModelResult<Self> {
1812        Ok(Self(embed_texts(app_config, texts).await?))
1813    }
1814
1815    /// The embeddings of `texts` in order, embedding the ones `self` lacks.
1816    async fn get_or_create(
1817        &self,
1818        app_config: &ApplicationConfiguration,
1819        texts: &[String],
1820    ) -> ModelResult<Vec<Vec<f32>>> {
1821        let missing: Vec<String> = texts
1822            .iter()
1823            .filter(|text| !self.0.contains_key(*text))
1824            .cloned()
1825            .collect();
1826        let created = embed_texts(app_config, missing).await?;
1827        texts
1828            .iter()
1829            .map(|text| {
1830                self.0
1831                    .get(text)
1832                    .or_else(|| created.get(text))
1833                    .cloned()
1834                    .ok_or_else(missing_model_error(
1835                        ModelErrorType::Generic,
1836                        "The embedding API returned too few embeddings.",
1837                    ))
1838            })
1839            .collect()
1840    }
1841
1842    /// The embedding of `text`, if any, embedding it if `self` lacks it.
1843    async fn get_or_create_one(
1844        &self,
1845        app_config: &ApplicationConfiguration,
1846        text: Option<&str>,
1847    ) -> ModelResult<Option<Vector>> {
1848        let Some(text) = text else {
1849            return Ok(None);
1850        };
1851        let embedding = self
1852            .get_or_create(app_config, &[text.to_string()])
1853            .await?
1854            .pop()
1855            .map(Vector::from);
1856        Ok(embedding)
1857    }
1858}
1859
1860async fn embed_texts(
1861    app_config: &ApplicationConfiguration,
1862    mut texts: Vec<String>,
1863) -> ModelResult<HashMap<String, Vec<f32>>> {
1864    texts.sort();
1865    texts.dedup();
1866    if texts.is_empty() {
1867        return Ok(HashMap::new());
1868    }
1869    let embeddings = create_embeddings(app_config, texts.clone()).await?;
1870    Ok(texts.into_iter().zip(embeddings).collect())
1871}
1872
1873pub async fn get_metadata(
1874    conn: &mut PgConnection,
1875    course_id: Uuid,
1876) -> ModelResult<CompleteCourseMetadata> {
1877    let prerequisites: Vec<CoursePrerequisite> =
1878        crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1879    let audiences: Vec<CourseAudience> =
1880        crate::course_audiences::get_by_course_id(conn, course_id).await?;
1881    let course_data = get_course(conn, course_id).await?;
1882    let instances =
1883        crate::course_instances::get_course_instances_for_course(conn, course_id).await?;
1884    let module = crate::course_modules::get_default_by_course_id(conn, course_id).await?;
1885
1886    let organization =
1887        crate::organizations::get_organization(conn, course_data.organization_id).await?;
1888
1889    let metadata = CompleteCourseMetadata {
1890        course: course_data,
1891        course_instances: instances,
1892        default_module: module,
1893        course_prerequisites: prerequisites,
1894        course_audiences: audiences,
1895        course_organization: organization,
1896    };
1897    Ok(metadata)
1898}
1899
1900/// Saves the course's title and description embeddings, keeping the stored one where an argument
1901/// is `None`.
1902pub async fn update_course_embeddings(
1903    conn: &mut PgConnection,
1904    course_id: Uuid,
1905    title_embedding: Option<Vector>,
1906    description_embedding: Option<Vector>,
1907) -> ModelResult<()> {
1908    sqlx::query!(
1909        r#"
1910INSERT INTO course_embeddings (
1911    course_id,
1912    title_embedding,
1913    description_embedding
1914)
1915VALUES ($1, $2, $3)
1916ON CONFLICT (course_id) WHERE deleted_at IS NULL
1917DO UPDATE SET
1918    title_embedding = COALESCE(
1919        $2,
1920        course_embeddings.title_embedding
1921    ),
1922    description_embedding = COALESCE(
1923        $3,
1924        course_embeddings.description_embedding
1925    )
1926"#,
1927        course_id,
1928        title_embedding,
1929        description_embedding,
1930    )
1931    .execute(conn)
1932    .await?;
1933
1934    Ok(())
1935}
1936
1937pub async fn get_by_description_vectors(
1938    conn: &mut PgConnection,
1939    query_vecs: Vec<Vec<f32>>,
1940    description_keywords: Vec<String>,
1941) -> ModelResult<Vec<Uuid>> {
1942    let vectors: Vec<Vector> = query_vecs.into_iter().map(Vector::from).collect();
1943    let res = sqlx::query_scalar!(
1944        r#"
1945SELECT id
1946FROM (
1947    SELECT
1948        c.id,
1949        LEAST(MIN(ce.title_embedding <#> v.embedding),
1950              MIN(ce.description_embedding <#> v.embedding)) AS distance
1951    FROM courses c, course_embeddings ce
1952    CROSS JOIN unnest($1::vector[]) AS v(embedding)
1953    WHERE c.deleted_at IS NULL AND ce.deleted_at IS NULL AND c.id = ce.course_id
1954    GROUP BY c.id
1955    ORDER BY distance ASC
1956    LIMIT 10
1957) t
1958UNION ALL
1959SELECT DISTINCT c.id
1960FROM courses c
1961CROSS JOIN unnest($2::text[]) AS k(keyword)
1962WHERE deleted_at IS NULL
1963  AND to_tsvector(
1964      c.content_search_language::regconfig,
1965      c.name || ' ' || coalesce(c.description, '')
1966  ) @@ websearch_to_tsquery(
1967      c.content_search_language::regconfig,
1968      k.keyword
1969  )
1970        "#,
1971        &vectors as _,
1972        &description_keywords
1973    )
1974    .fetch_all(conn)
1975    .await?;
1976    Ok(res.into_iter().flatten().collect())
1977}
1978
1979#[cfg(test)]
1980mod test {
1981    use super::*;
1982    use crate::{course_language_groups, courses, test_helper::*};
1983
1984    mod language_code_validation {
1985        use super::*;
1986
1987        #[tokio::test]
1988        async fn allows_valid_language_code() {
1989            let app_config =
1990                init_app_conf().expect("Application Configuration initialization failed");
1991            insert_data!(:tx, user: _user, :org);
1992            let course_language_group_id = course_language_groups::insert(
1993                tx.as_mut(),
1994                PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1995                "test-clg-allows-valid",
1996            )
1997            .await
1998            .unwrap();
1999            let new_course = create_new_course(org, "en-US");
2000            let res = courses::insert(
2001                tx.as_mut(),
2002                &app_config,
2003                PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
2004                course_language_group_id,
2005                &new_course,
2006            )
2007            .await;
2008            assert!(res.is_ok());
2009        }
2010
2011        #[tokio::test]
2012        async fn disallows_empty_language_code() {
2013            let app_config =
2014                init_app_conf().expect("Application Configuration initialization failed");
2015            insert_data!(:tx, user: _user, :org);
2016            let course_language_group_id = course_language_groups::insert(
2017                tx.as_mut(),
2018                PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
2019                "test-clg-disallows-empty",
2020            )
2021            .await
2022            .unwrap();
2023            let new_course = create_new_course(org, "");
2024            let res = courses::insert(
2025                tx.as_mut(),
2026                &app_config,
2027                PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
2028                course_language_group_id,
2029                &new_course,
2030            )
2031            .await;
2032            assert!(res.is_err());
2033        }
2034
2035        #[tokio::test]
2036        async fn disallows_wrong_case_language_code() {
2037            let app_config =
2038                init_app_conf().expect("Application Configuration initialization failed");
2039            insert_data!(:tx, user: _user, :org);
2040            let course_language_group_id = course_language_groups::insert(
2041                tx.as_mut(),
2042                PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
2043                "test-clg-disallows-wrong-case",
2044            )
2045            .await
2046            .unwrap();
2047            let new_course = create_new_course(org, "en-us");
2048            let res = courses::insert(
2049                tx.as_mut(),
2050                &app_config,
2051                PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
2052                course_language_group_id,
2053                &new_course,
2054            )
2055            .await;
2056            assert!(res.is_err());
2057        }
2058
2059        #[tokio::test]
2060        async fn disallows_underscore_in_language_code() {
2061            let app_config =
2062                init_app_conf().expect("Application Configuration initialization failed");
2063            insert_data!(:tx, user: _user, :org);
2064            let course_language_group_id = course_language_groups::insert(
2065                tx.as_mut(),
2066                PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
2067                "test-clg-disallows-underscore",
2068            )
2069            .await
2070            .unwrap();
2071            let new_course = create_new_course(org, "en_US");
2072            let res = courses::insert(
2073                tx.as_mut(),
2074                &app_config,
2075                PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
2076                course_language_group_id,
2077                &new_course,
2078            )
2079            .await;
2080            assert!(res.is_err());
2081        }
2082
2083        fn create_new_course(organization_id: Uuid, language_code: &str) -> NewCourse {
2084            NewCourse {
2085                name: "".to_string(),
2086                slug: "".to_string(),
2087                organization_id,
2088                language_code: language_code.to_string(),
2089                teacher_in_charge_name: "teacher".to_string(),
2090                teacher_in_charge_email: "teacher@example.com".to_string(),
2091                description: "description".to_string(),
2092                is_draft: false,
2093                is_test_mode: false,
2094                is_unlisted: false,
2095                copy_user_permissions: false,
2096                is_joinable_by_code_only: false,
2097                join_code: None,
2098                ask_marketing_consent: false,
2099                flagged_answers_threshold: Some(3),
2100                can_add_chatbot: false,
2101            }
2102        }
2103    }
2104
2105    mod ai_policy {
2106        use super::*;
2107
2108        #[tokio::test]
2109        async fn update_course_round_trips_ai_policy_fields() {
2110            let app_config =
2111                init_app_conf().expect("Application Configuration initialization failed");
2112            insert_data!(:tx, :user, :org);
2113            let course_language_group_id = course_language_groups::insert(
2114                tx.as_mut(),
2115                PKeyPolicy::Fixed(Uuid::parse_str("a1b2c3d4-0000-0000-0000-000000000001").unwrap()),
2116                "test-clg-ai-policy",
2117            )
2118            .await
2119            .unwrap();
2120            let new_course = NewCourse {
2121                name: "AI policy course".to_string(),
2122                slug: "ai-policy-course".to_string(),
2123                organization_id: org,
2124                language_code: "en-US".to_string(),
2125                teacher_in_charge_name: "teacher".to_string(),
2126                teacher_in_charge_email: "teacher@example.com".to_string(),
2127                description: "description".to_string(),
2128                is_draft: false,
2129                is_test_mode: false,
2130                is_unlisted: false,
2131                copy_user_permissions: false,
2132                is_joinable_by_code_only: false,
2133                join_code: None,
2134                ask_marketing_consent: false,
2135                flagged_answers_threshold: Some(3),
2136                can_add_chatbot: false,
2137            };
2138            let course_id = courses::insert(
2139                tx.as_mut(),
2140                &app_config,
2141                PKeyPolicy::Fixed(Uuid::parse_str("a1b2c3d4-0000-0000-0000-000000000002").unwrap()),
2142                course_language_group_id,
2143                &new_course,
2144            )
2145            .await
2146            .unwrap();
2147
2148            // New courses default to the generic notice (NotSet / Unknown).
2149            let created = courses::get_course(tx.as_mut(), course_id).await.unwrap();
2150            assert_eq!(created.ai_policy, CourseAiPolicy::NotSet);
2151            assert_eq!(created.course_material_ai_instructions, None);
2152
2153            // A teacher selects a policy and indicates the material has its own AI instructions.
2154            let updated = courses::update_course(
2155                tx.as_mut(),
2156                &app_config,
2157                course_id,
2158                CourseUpdate {
2159                    name: created.name.clone(),
2160                    flagged_answers_threshold: 3,
2161                    ai_policy: CourseAiPolicy::Limited,
2162                    course_material_ai_instructions: Some(true),
2163                    ..Default::default()
2164                },
2165                user,
2166                false,
2167            )
2168            .await
2169            .unwrap();
2170            assert_eq!(updated.ai_policy, CourseAiPolicy::Limited);
2171            assert_eq!(updated.course_material_ai_instructions, Some(true));
2172
2173            // The change is persisted for subsequent reads (which feed the student dialog).
2174            let reread = courses::get_course(tx.as_mut(), course_id).await.unwrap();
2175            assert_eq!(reread.ai_policy, CourseAiPolicy::Limited);
2176            assert_eq!(reread.course_material_ai_instructions, Some(true));
2177        }
2178    }
2179
2180    mod enrolled_courses_with_exercise_types {
2181        use super::*;
2182        use crate::{
2183            course_instance_enrollments::{self, NewCourseInstanceEnrollment},
2184            exercise_tasks::{self, NewExerciseTask},
2185        };
2186
2187        #[tokio::test]
2188        async fn gets_enrolled_courses_with_exercise_types() {
2189            insert_data!(:tx, user:user_id, :org, course:course_id, :instance, course_module:_course_module_id, chapter:chapter_id, page:page_id, :exercise, slide:exercise_slide_id);
2190
2191            course_instance_enrollments::insert_enrollment_and_set_as_current(
2192                tx.as_mut(),
2193                NewCourseInstanceEnrollment {
2194                    course_id,
2195                    user_id,
2196                    course_instance_id: instance.id,
2197                },
2198            )
2199            .await
2200            .unwrap();
2201            let tmc = ["tmc".to_string()];
2202            let courses = get_enrolled_courses_with_exercise_types(tx.as_mut(), user_id, &tmc)
2203                .await
2204                .unwrap();
2205            assert!(
2206                courses.is_empty(),
2207                "user should not be enrolled on any course with tmc exercises"
2208            );
2209
2210            exercise_tasks::insert(
2211                tx.as_mut(),
2212                PKeyPolicy::Generate,
2213                NewExerciseTask {
2214                    assignment: Vec::new(),
2215                    exercise_slide_id,
2216                    exercise_type: "tmc".to_string(),
2217                    model_solution_spec: None,
2218                    private_spec: None,
2219                    public_spec: None,
2220                    order_number: 1,
2221                },
2222            )
2223            .await
2224            .unwrap();
2225            let courses = get_enrolled_courses_with_exercise_types(tx.as_mut(), user_id, &tmc)
2226                .await
2227                .unwrap();
2228            assert_eq!(
2229                courses.len(),
2230                1,
2231                "user should be enrolled on one course with tmc exercises"
2232            );
2233
2234            // A type list the course has no task for must not match, and neither must an empty list.
2235            let others = ["quizzes".to_string(), "example-exercise".to_string()];
2236            assert!(
2237                get_enrolled_courses_with_exercise_types(tx.as_mut(), user_id, &others)
2238                    .await
2239                    .unwrap()
2240                    .is_empty()
2241            );
2242            assert!(
2243                get_enrolled_courses_with_exercise_types(tx.as_mut(), user_id, &[])
2244                    .await
2245                    .unwrap()
2246                    .is_empty()
2247            );
2248
2249            // Any list containing a type the course does have matches.
2250            let mixed = ["quizzes".to_string(), "tmc".to_string()];
2251            assert_eq!(
2252                get_enrolled_courses_with_exercise_types(tx.as_mut(), user_id, &mixed)
2253                    .await
2254                    .unwrap()
2255                    .len(),
2256                1
2257            );
2258            tx.rollback().await;
2259        }
2260
2261        #[tokio::test]
2262        async fn lists_a_course_once_when_enrolled_on_several_of_its_instances() {
2263            insert_data!(:tx, user:user_id, :org, course:course_id, :instance, course_module:_course_module_id, chapter:_chapter_id, page:_page_id, :exercise, slide:exercise_slide_id);
2264            exercise_tasks::insert(
2265                tx.as_mut(),
2266                PKeyPolicy::Generate,
2267                NewExerciseTask {
2268                    assignment: Vec::new(),
2269                    exercise_slide_id,
2270                    exercise_type: "tmc".to_string(),
2271                    model_solution_spec: None,
2272                    private_spec: None,
2273                    public_spec: None,
2274                    order_number: 1,
2275                },
2276            )
2277            .await
2278            .unwrap();
2279            let default_instance =
2280                crate::course_instances::get_default_by_course_id(tx.as_mut(), course_id)
2281                    .await
2282                    .unwrap();
2283            for course_instance_id in [instance.id, default_instance.id] {
2284                course_instance_enrollments::insert_enrollment_and_set_as_current(
2285                    tx.as_mut(),
2286                    NewCourseInstanceEnrollment {
2287                        course_id,
2288                        user_id,
2289                        course_instance_id,
2290                    },
2291                )
2292                .await
2293                .unwrap();
2294            }
2295
2296            let courses = get_enrolled_courses_with_exercise_types(
2297                tx.as_mut(),
2298                user_id,
2299                &["tmc".to_string()],
2300            )
2301            .await
2302            .unwrap();
2303            assert_eq!(
2304                courses.iter().map(|c| c.id).collect::<Vec<_>>(),
2305                vec![course_id]
2306            );
2307            tx.rollback().await;
2308        }
2309    }
2310}