Skip to main content

headless_lms_models/library/
copying.rs

1use std::collections::{HashMap, HashSet};
2
3use serde_json::Value;
4
5use crate::ModelResult;
6use crate::course_instances;
7use crate::course_instances::NewCourseInstance;
8use crate::course_language_groups;
9use crate::courses::Course;
10use crate::courses::CourseAiPolicy;
11use crate::courses::NewCourse;
12use crate::courses::get_course;
13use crate::exams;
14use crate::exams::Exam;
15use crate::exams::NewExam;
16use crate::page_history::{self, HistoryChangeReason, PageHistoryContent};
17use crate::pages;
18use crate::prelude::*;
19
20pub async fn copy_course(
21    conn: &mut PgConnection,
22    course_id: Uuid,
23    new_course: &NewCourse,
24    same_language_group: bool,
25    user_id: Uuid,
26) -> ModelResult<Course> {
27    let mut tx = conn.begin().await?;
28    let parent_course = get_course(&mut tx, course_id).await?;
29    let course_language_group_id = if same_language_group {
30        parent_course.course_language_group_id
31    } else {
32        course_language_groups::insert(&mut tx, PKeyPolicy::Generate, &new_course.slug).await?
33    };
34
35    let copied_course = copy_course_with_language_group(
36        &mut tx,
37        course_id,
38        course_language_group_id,
39        new_course,
40        user_id,
41    )
42    .await?;
43
44    tx.commit().await?;
45
46    Ok(copied_course)
47}
48
49pub async fn copy_course_with_language_group(
50    conn: &mut PgConnection,
51    src_course_id: Uuid,
52    target_clg_id: Uuid,
53    new_course: &NewCourse,
54    user_id: Uuid,
55) -> ModelResult<Course> {
56    let parent_course = get_course(conn, src_course_id).await?;
57    let same_clg = target_clg_id == parent_course.course_language_group_id;
58    let description = if new_course.description.trim().is_empty() {
59        parent_course.description.clone()
60    } else {
61        Some(new_course.description.clone())
62    };
63
64    let mut tx = conn.begin().await?;
65
66    let copied_course = sqlx::query_as!(
67        Course,
68        r#"
69INSERT INTO courses (
70    name,
71    organization_id,
72    slug,
73    content_search_language,
74    language_code,
75    copied_from,
76    course_language_group_id,
77    is_draft,
78    base_module_completion_requires_n_submodule_completions,
79    can_add_chatbot,
80    is_unlisted,
81    is_joinable_by_code_only,
82    join_code,
83    ask_marketing_consent,
84    description,
85    flagged_answers_threshold,
86    flagged_answers_skip_manual_review_and_allow_retry,
87    cheater_detection_enabled,
88    chapter_locking_enabled,
89    ai_policy,
90    course_material_ai_instructions,
91    is_test_mode
92  )
93VALUES (
94    $1,
95    $2,
96    $3,
97    $4,
98    $5,
99    $6,
100    $7,
101    $8,
102    $9,
103    $10,
104    $11,
105    $12,
106    $13,
107    $14,
108    $15,
109    $16,
110    $17,
111    $18,
112    $19,
113    $20,
114    $21,
115    $22
116  )
117RETURNING id,
118  name,
119  created_at,
120  updated_at,
121  organization_id,
122  deleted_at,
123  slug,
124  content_search_language::text,
125  language_code,
126  copied_from,
127  course_language_group_id,
128  description,
129  is_draft,
130  is_test_mode,
131  base_module_completion_requires_n_submodule_completions,
132  can_add_chatbot,
133  is_unlisted,
134  is_joinable_by_code_only,
135  join_code,
136  ask_marketing_consent,
137  flagged_answers_threshold,
138  flagged_answers_skip_manual_review_and_allow_retry,
139  closed_at,
140  closed_additional_message,
141  closed_course_successor_id,
142  chapter_locking_enabled,
143  cheater_detection_enabled,
144  ai_policy,
145  course_material_ai_instructions
146        "#,
147        new_course.name,
148        new_course.organization_id,
149        new_course.slug,
150        parent_course.content_search_language as _,
151        new_course.language_code,
152        parent_course.id,
153        target_clg_id,
154        new_course.is_draft,
155        parent_course.base_module_completion_requires_n_submodule_completions,
156        parent_course.can_add_chatbot,
157        new_course.is_unlisted,
158        new_course.is_joinable_by_code_only,
159        new_course.join_code,
160        new_course.ask_marketing_consent,
161        description,
162        parent_course.flagged_answers_threshold,
163        parent_course.flagged_answers_skip_manual_review_and_allow_retry,
164        parent_course.cheater_detection_enabled,
165        parent_course.chapter_locking_enabled,
166        parent_course.ai_policy as CourseAiPolicy,
167        parent_course.course_material_ai_instructions,
168        new_course.is_test_mode,
169    )
170    .fetch_one(&mut *tx)
171    .await?;
172
173    let mut content_rewrite = ContentRewrite::new(copied_course.id);
174    content_rewrite.source_course_id = Some(src_course_id);
175    content_rewrite.course_slugs = Some((&parent_course.slug, &new_course.slug));
176
177    content_rewrite.course_module_ids =
178        copy_course_modules(&mut tx, copied_course.id, src_course_id).await?;
179    copy_course_chapters(&mut tx, copied_course.id, src_course_id).await?;
180
181    if new_course.copy_user_permissions {
182        copy_user_permissions(&mut tx, copied_course.id, src_course_id, user_id).await?;
183    }
184
185    let page_contents = copy_course_pages_and_return_contents(
186        &mut tx,
187        copied_course.id,
188        src_course_id,
189        target_clg_id,
190        same_clg,
191    )
192    .await?;
193
194    set_chapter_front_pages(&mut tx, copied_course.id).await?;
195
196    content_rewrite.exercise_ids = copy_course_exercises(
197        &mut tx,
198        copied_course.id,
199        src_course_id,
200        target_clg_id,
201        same_clg,
202    )
203    .await?;
204
205    copy_exercise_slides(&mut tx, copied_course.id, src_course_id).await?;
206    copy_exercise_tasks(&mut tx, copied_course.id, src_course_id).await?;
207
208    // We don't copy course instances at the moment because they are not related to the course content, and someone might want to take the content without the instances. We could add an option to copy them in the future.
209    let course_instance = course_instances::insert(
210        &mut tx,
211        PKeyPolicy::Generate,
212        NewCourseInstance {
213            course_id: copied_course.id,
214            name: None,
215            description: None,
216            support_email: None,
217            teacher_in_charge_name: &new_course.teacher_in_charge_name,
218            teacher_in_charge_email: &new_course.teacher_in_charge_email,
219            opening_time: None,
220            closing_time: None,
221        },
222    )
223    .await?;
224    content_rewrite.course_instance_id = Some(course_instance.id);
225
226    copy_peer_or_self_review_configs(&mut tx, copied_course.id, src_course_id).await?;
227    copy_peer_or_self_review_questions(&mut tx, copied_course.id, src_course_id).await?;
228    copy_material_references(&mut tx, copied_course.id, src_course_id).await?;
229    copy_glossary_entries(&mut tx, copied_course.id, src_course_id).await?;
230
231    // Copy course configurations and optional content
232    copy_certificate_configurations_and_requirements(&mut tx, copied_course.id, src_course_id)
233        .await?;
234    content_rewrite.chatbot_configuration_ids =
235        copy_chatbot_configurations(&mut tx, copied_course.id, src_course_id).await?;
236    copy_cheater_thresholds(&mut tx, copied_course.id, src_course_id).await?;
237    copy_course_custom_privacy_policy_checkbox_texts(&mut tx, copied_course.id, src_course_id)
238        .await?;
239    copy_exercise_repositories(&mut tx, copied_course.id, src_course_id).await?;
240    copy_partners_blocks(&mut tx, copied_course.id, src_course_id).await?;
241    copy_privacy_links(&mut tx, copied_course.id, src_course_id).await?;
242    content_rewrite.consent_form_question_ids =
243        copy_research_consent_forms_and_questions(&mut tx, copied_course.id, src_course_id).await?;
244    copy_email_templates(&mut tx, copied_course.id, src_course_id).await?;
245    content_rewrite.code_giveaway_ids =
246        copy_code_giveaways(&mut tx, copied_course.id, src_course_id).await?;
247    copy_page_audio_files(&mut tx, copied_course.id, src_course_id).await?;
248
249    let copied_page_ids = page_contents.keys().copied().collect::<Vec<_>>();
250    rewrite_page_contents(&mut tx, page_contents, &content_rewrite).await?;
251    rewrite_course_texts(&mut tx, copied_course.id, &content_rewrite).await?;
252    insert_page_history_for_copies(&mut tx, &copied_page_ids, user_id).await?;
253
254    tx.commit().await?;
255
256    Ok(copied_course)
257}
258
259pub async fn copy_exam(
260    conn: &mut PgConnection,
261    parent_exam_id: &Uuid,
262    new_exam: &NewExam,
263    user_id: Uuid,
264) -> ModelResult<Exam> {
265    let mut tx = conn.begin().await?;
266    let copied_exam = copy_exam_content(&mut tx, parent_exam_id, new_exam, None, user_id).await?;
267    tx.commit().await?;
268    Ok(copied_exam)
269}
270
271async fn copy_exam_content(
272    tx: &mut PgConnection,
273    parent_exam_id: &Uuid,
274    new_exam: &NewExam,
275    new_exam_id: Option<Uuid>,
276    user_id: Uuid,
277) -> ModelResult<Exam> {
278    let parent_exam = exams::get(tx, *parent_exam_id).await?;
279
280    let parent_exam_fields = sqlx::query!(
281        "
282SELECT *
283FROM exams
284WHERE id = $1
285        ",
286        parent_exam.id
287    )
288    .fetch_one(&mut *tx)
289    .await?;
290
291    let final_exam_id = new_exam_id.unwrap_or_else(Uuid::new_v4);
292
293    // create new exam
294    let copied_exam = sqlx::query!(
295        "
296INSERT INTO exams(
297    id,
298    name,
299    organization_id,
300    instructions,
301    starts_at,
302    ends_at,
303    language,
304    time_minutes,
305    minimum_points_treshold,
306    grade_manually
307  )
308VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
309RETURNING *
310        ",
311        final_exam_id,
312        new_exam.name,
313        parent_exam_fields.organization_id,
314        parent_exam.instructions,
315        new_exam.starts_at,
316        new_exam.ends_at,
317        parent_exam_fields.language,
318        new_exam.time_minutes,
319        new_exam.minimum_points_treshold,
320        new_exam.grade_manually,
321    )
322    .fetch_one(&mut *tx)
323    .await?;
324
325    let page_contents =
326        copy_exam_pages_and_return_contents(&mut *tx, copied_exam.id, parent_exam.id).await?;
327
328    let mut content_rewrite = ContentRewrite::new(copied_exam.id);
329    content_rewrite.exercise_ids =
330        copy_exam_exercises(&mut *tx, copied_exam.id, parent_exam.id).await?;
331
332    copy_exercise_slides(&mut *tx, copied_exam.id, parent_exam.id).await?;
333    copy_exercise_tasks(&mut *tx, copied_exam.id, parent_exam.id).await?;
334    copy_page_audio_files(&mut *tx, copied_exam.id, parent_exam.id).await?;
335
336    let copied_page_ids = page_contents.keys().copied().collect::<Vec<_>>();
337    rewrite_page_contents(&mut *tx, page_contents, &content_rewrite).await?;
338    insert_page_history_for_copies(&mut *tx, &copied_page_ids, user_id).await?;
339
340    let get_page_id = sqlx::query!("SELECT id FROM pages WHERE exam_id = $1;", copied_exam.id)
341        .fetch_one(&mut *tx)
342        .await?;
343
344    Ok(Exam {
345        courses: vec![], // no related courses on newly copied exam
346        ends_at: copied_exam.ends_at,
347        starts_at: copied_exam.starts_at,
348        id: copied_exam.id,
349        instructions: copied_exam.instructions,
350        name: copied_exam.name,
351        time_minutes: copied_exam.time_minutes,
352        page_id: get_page_id.id,
353        minimum_points_treshold: copied_exam.minimum_points_treshold,
354        language: copied_exam
355            .language
356            .unwrap_or_else(|| parent_exam_fields.language.unwrap_or("en-US".to_string())),
357        grade_manually: copied_exam.grade_manually,
358    })
359}
360
361/// Points references inside copied block content at the copy.
362///
363/// Every copied row's id is `uuid_generate_v5(namespace_id, source_id)`; the id sets hold the ids of
364/// the rows that were actually copied, so references to anything left behind can be told apart.
365struct ContentRewrite<'a> {
366    namespace_id: Uuid,
367    source_course_id: Option<Uuid>,
368    course_instance_id: Option<Uuid>,
369    /// `(source slug, copy slug)`.
370    course_slugs: Option<(&'a str, &'a str)>,
371    exercise_ids: HashSet<Uuid>,
372    chatbot_configuration_ids: HashSet<Uuid>,
373    course_module_ids: HashSet<Uuid>,
374    code_giveaway_ids: HashSet<Uuid>,
375    consent_form_question_ids: HashSet<Uuid>,
376}
377
378impl<'a> ContentRewrite<'a> {
379    fn new(namespace_id: Uuid) -> Self {
380        Self {
381            namespace_id,
382            source_course_id: None,
383            course_instance_id: None,
384            course_slugs: None,
385            exercise_ids: HashSet::new(),
386            chatbot_configuration_ids: HashSet::new(),
387            course_module_ids: HashSet::new(),
388            code_giveaway_ids: HashSet::new(),
389            consent_form_question_ids: HashSet::new(),
390        }
391    }
392
393    /// The copy of the row `source_id` names, if that row was copied into `copied_ids`.
394    fn copy_of(&self, copied_ids: &HashSet<Uuid>, source_id: &Value) -> Option<Value> {
395        let source_id = Uuid::parse_str(source_id.as_str()?).ok()?;
396        let copied_id = Uuid::new_v5(&self.namespace_id, source_id.to_string().as_bytes());
397        copied_ids
398            .contains(&copied_id)
399            .then(|| Value::String(copied_id.to_string()))
400    }
401
402    fn rewrite_content(&self, content: &Value) -> Value {
403        match content {
404            Value::Array(blocks) => Value::Array(self.rewrite_blocks(blocks.clone())),
405            other => other.clone(),
406        }
407    }
408
409    fn rewrite_blocks(&self, blocks: Vec<Value>) -> Vec<Value> {
410        blocks
411            .into_iter()
412            .filter_map(|block| self.rewrite_block(block))
413            .collect()
414    }
415
416    /// `None` drops the block.
417    fn rewrite_block(&self, mut block: Value) -> Option<Value> {
418        let name = block["name"].as_str().unwrap_or_default().to_string();
419        if name == "moocfi/research-consent-question"
420            && let Some(copied_id) =
421                self.copy_of(&self.consent_form_question_ids, &block["clientId"])
422        {
423            block["clientId"] = copied_id;
424        }
425        if let Some(attributes) = block.get_mut("attributes").and_then(Value::as_object_mut) {
426            match name.as_str() {
427                // The exercise was left behind, so the block could only render as broken.
428                "moocfi/exercise" => {
429                    if let Some(source_id) = attributes.get("id").filter(|id| id.is_string()) {
430                        let copied_id = self.copy_of(&self.exercise_ids, source_id)?;
431                        attributes.insert("id".to_string(), copied_id);
432                    }
433                }
434                "moocfi/chatbot" => {
435                    if let Some(copied_id) = attributes
436                        .get("chatbotConfigurationId")
437                        .and_then(|id| self.copy_of(&self.chatbot_configuration_ids, id))
438                    {
439                        attributes.insert("chatbotConfigurationId".to_string(), copied_id);
440                    }
441                    if let Some(source_course_id) = self.source_course_id
442                        && attributes.get("courseId").and_then(Value::as_str)
443                            == Some(source_course_id.to_string().as_str())
444                    {
445                        attributes.insert(
446                            "courseId".to_string(),
447                            Value::String(self.namespace_id.to_string()),
448                        );
449                    }
450                }
451                "moocfi/conditional-block" => {
452                    if let Some(Value::Array(module_ids)) = attributes.get_mut("module_completion")
453                    {
454                        for module_id in module_ids.iter_mut() {
455                            if let Some(copied_id) =
456                                self.copy_of(&self.course_module_ids, module_id)
457                            {
458                                *module_id = copied_id;
459                            }
460                        }
461                    }
462                    // Instances are not copied. Requiring the copy's instance keeps the content
463                    // gated and shows the condition in the editor, where a source id would not.
464                    if let Some(course_instance_id) = self.course_instance_id
465                        && let Some(Value::Array(instance_ids)) =
466                            attributes.get_mut("instance_enrollment")
467                        && !instance_ids.is_empty()
468                    {
469                        *instance_ids = vec![Value::String(course_instance_id.to_string())];
470                    }
471                }
472                "moocfi/code-giveaway" => {
473                    if let Some(copied_id) = attributes
474                        .get("code_giveaway_id")
475                        .and_then(|id| self.copy_of(&self.code_giveaway_ids, id))
476                    {
477                        attributes.insert("code_giveaway_id".to_string(), copied_id);
478                    }
479                }
480                _ => {}
481            }
482            if let Some((source_slug, copy_slug)) = self.course_slugs {
483                for (key, value) in attributes.iter_mut() {
484                    rewrite_course_links(value, Some(key), source_slug, copy_slug);
485                }
486            }
487        }
488        if let Some(Value::Array(inner_blocks)) = block.get_mut("innerBlocks") {
489            *inner_blocks = self.rewrite_blocks(std::mem::take(inner_blocks));
490        }
491        Some(block)
492    }
493}
494
495/// Rewrites `/courses/<source_slug>` link targets in `value`: whole `url`/`href` attributes, and
496/// `href` values inside HTML strings. Visible text is left alone.
497fn rewrite_course_links(value: &mut Value, key: Option<&str>, source_slug: &str, copy_slug: &str) {
498    match value {
499        Value::String(text) => {
500            if !text.contains(&format!("/courses/{source_slug}")) {
501                return;
502            }
503            *text = if matches!(key, Some("url" | "href")) {
504                rewrite_course_path(text, source_slug, copy_slug)
505            } else {
506                rewrite_html_hrefs(text, source_slug, copy_slug)
507            };
508        }
509        Value::Array(items) => {
510            for item in items {
511                rewrite_course_links(item, None, source_slug, copy_slug);
512            }
513        }
514        Value::Object(fields) => {
515            for (field_key, field_value) in fields.iter_mut() {
516                rewrite_course_links(field_value, Some(field_key), source_slug, copy_slug);
517            }
518        }
519        _ => {}
520    }
521}
522
523fn rewrite_html_hrefs(html: &str, source_slug: &str, copy_slug: &str) -> String {
524    let mut rewritten = String::with_capacity(html.len());
525    let mut rest = html;
526    while let Some(attribute_start) = rest.find("href=") {
527        let quote_start = attribute_start + "href=".len();
528        let Some(quote) = rest[quote_start..]
529            .chars()
530            .next()
531            .filter(|c| matches!(c, '"' | '\''))
532        else {
533            rewritten.push_str(&rest[..quote_start]);
534            rest = &rest[quote_start..];
535            continue;
536        };
537        let target_start = quote_start + quote.len_utf8();
538        let Some(target_len) = rest[target_start..].find(quote) else {
539            break;
540        };
541        let target_end = target_start + target_len;
542        rewritten.push_str(&rest[..target_start]);
543        rewritten.push_str(&rewrite_course_path(
544            &rest[target_start..target_end],
545            source_slug,
546            copy_slug,
547        ));
548        rest = &rest[target_end..];
549    }
550    rewritten.push_str(rest);
551    rewritten
552}
553
554fn rewrite_course_path(target: &str, source_slug: &str, copy_slug: &str) -> String {
555    let source_path = format!("/courses/{source_slug}");
556    let mut rewritten = String::with_capacity(target.len());
557    let mut rest = target;
558    while let Some(path_start) = rest.find(&source_path) {
559        let path_end = path_start + source_path.len();
560        let ends_at_slug = rest[path_end..]
561            .chars()
562            .next()
563            .is_none_or(|c| matches!(c, '/' | '?' | '#' | '"'));
564        rewritten.push_str(&rest[..path_start]);
565        if ends_at_slug {
566            rewritten.push_str("/courses/");
567            rewritten.push_str(copy_slug);
568        } else {
569            rewritten.push_str(&source_path);
570        }
571        rest = &rest[path_end..];
572    }
573    rewritten.push_str(rest);
574    rewritten
575}
576
577async fn rewrite_page_contents(
578    tx: &mut PgConnection,
579    page_contents: HashMap<Uuid, Value>,
580    content_rewrite: &ContentRewrite<'_>,
581) -> ModelResult<()> {
582    let (ids, contents) = changed_rows(page_contents, |content| {
583        content_rewrite.rewrite_content(content)
584    });
585    if !ids.is_empty() {
586        sqlx::query!(
587            "
588UPDATE pages
589SET content = rewritten.content
590FROM UNNEST($1::uuid [], $2::jsonb []) AS rewritten(id, content)
591WHERE pages.id = rewritten.id;
592            ",
593            &ids,
594            &contents
595        )
596        .execute(&mut *tx)
597        .await?;
598    }
599    Ok(())
600}
601
602/// The ids and rewritten values of the rows whose value `rewrite` changes.
603fn changed_rows<T: PartialEq>(
604    rows: impl IntoIterator<Item = (Uuid, T)>,
605    rewrite: impl Fn(&T) -> T,
606) -> (Vec<Uuid>, Vec<T>) {
607    rows.into_iter()
608        .filter_map(|(id, value)| {
609            let rewritten = rewrite(&value);
610            (rewritten != value).then_some((id, rewritten))
611        })
612        .unzip()
613}
614
615/// Applies `content_rewrite` to the copied course's block content and links outside pages.
616async fn rewrite_course_texts(
617    tx: &mut PgConnection,
618    course_id: Uuid,
619    content_rewrite: &ContentRewrite<'_>,
620) -> ModelResult<()> {
621    let assignments = sqlx::query!(
622        "
623SELECT t.id,
624  t.assignment
625FROM exercise_tasks t
626  JOIN exercise_slides s ON s.id = t.exercise_slide_id
627  JOIN exercises e ON e.id = s.exercise_id
628WHERE e.course_id = $1
629  AND t.deleted_at IS NULL;
630        ",
631        course_id
632    )
633    .fetch_all(&mut *tx)
634    .await?;
635    let (ids, assignments) = changed_rows(
636        assignments
637            .into_iter()
638            .map(|task| (task.id, task.assignment)),
639        |assignment| content_rewrite.rewrite_content(assignment),
640    );
641    if !ids.is_empty() {
642        sqlx::query!(
643            "
644UPDATE exercise_tasks
645SET assignment = rewritten.assignment
646FROM UNNEST($1::uuid [], $2::jsonb []) AS rewritten(id, assignment)
647WHERE exercise_tasks.id = rewritten.id;
648            ",
649            &ids,
650            &assignments
651        )
652        .execute(&mut *tx)
653        .await?;
654    }
655
656    let review_instructions = sqlx::query!(
657        "
658SELECT id,
659  review_instructions
660FROM peer_or_self_review_configs
661WHERE course_id = $1
662  AND deleted_at IS NULL;
663        ",
664        course_id
665    )
666    .fetch_all(&mut *tx)
667    .await?;
668    let (ids, review_instructions) = changed_rows(
669        review_instructions
670            .into_iter()
671            .filter_map(|config| Some((config.id, config.review_instructions?))),
672        |instructions| content_rewrite.rewrite_content(instructions),
673    );
674    if !ids.is_empty() {
675        sqlx::query!(
676            "
677UPDATE peer_or_self_review_configs
678SET review_instructions = rewritten.review_instructions
679FROM UNNEST($1::uuid [], $2::jsonb []) AS rewritten(id, review_instructions)
680WHERE peer_or_self_review_configs.id = rewritten.id;
681            ",
682            &ids,
683            &review_instructions
684        )
685        .execute(&mut *tx)
686        .await?;
687    }
688
689    let research_forms = sqlx::query!(
690        "
691SELECT id,
692  content
693FROM course_specific_research_consent_forms
694WHERE course_id = $1
695  AND deleted_at IS NULL;
696        ",
697        course_id
698    )
699    .fetch_all(&mut *tx)
700    .await?;
701    let (ids, contents) = changed_rows(
702        research_forms
703            .into_iter()
704            .map(|form| (form.id, form.content)),
705        |content| content_rewrite.rewrite_content(content),
706    );
707    if !ids.is_empty() {
708        sqlx::query!(
709            "
710UPDATE course_specific_research_consent_forms
711SET content = rewritten.content
712FROM UNNEST($1::uuid [], $2::jsonb []) AS rewritten(id, content)
713WHERE course_specific_research_consent_forms.id = rewritten.id;
714            ",
715            &ids,
716            &contents
717        )
718        .execute(&mut *tx)
719        .await?;
720    }
721
722    let email_templates = sqlx::query!(
723        "
724SELECT id,
725  content
726FROM email_templates
727WHERE course_id = $1
728  AND deleted_at IS NULL;
729        ",
730        course_id
731    )
732    .fetch_all(&mut *tx)
733    .await?;
734    let (ids, contents) = changed_rows(
735        email_templates
736            .into_iter()
737            .filter_map(|template| Some((template.id, template.content?))),
738        |content| content_rewrite.rewrite_content(content),
739    );
740    if !ids.is_empty() {
741        sqlx::query!(
742            "
743UPDATE email_templates
744SET content = rewritten.content
745FROM UNNEST($1::uuid [], $2::jsonb []) AS rewritten(id, content)
746WHERE email_templates.id = rewritten.id;
747            ",
748            &ids,
749            &contents
750        )
751        .execute(&mut *tx)
752        .await?;
753    }
754
755    let partners_blocks = sqlx::query!(
756        "
757SELECT id,
758  content
759FROM partners_blocks
760WHERE course_id = $1
761  AND deleted_at IS NULL;
762        ",
763        course_id
764    )
765    .fetch_all(&mut *tx)
766    .await?;
767    let (ids, contents) = changed_rows(
768        partners_blocks
769            .into_iter()
770            .filter_map(|partners_block| Some((partners_block.id, partners_block.content?))),
771        |content| content_rewrite.rewrite_content(content),
772    );
773    if !ids.is_empty() {
774        sqlx::query!(
775            "
776UPDATE partners_blocks
777SET content = rewritten.content
778FROM UNNEST($1::uuid [], $2::jsonb []) AS rewritten(id, content)
779WHERE partners_blocks.id = rewritten.id;
780            ",
781            &ids,
782            &contents
783        )
784        .execute(&mut *tx)
785        .await?;
786    }
787
788    let Some((source_slug, copy_slug)) = content_rewrite.course_slugs else {
789        return Ok(());
790    };
791
792    let checkbox_texts = sqlx::query!(
793        "
794SELECT id,
795  text_html
796FROM course_custom_privacy_policy_checkbox_texts
797WHERE course_id = $1
798  AND deleted_at IS NULL;
799        ",
800        course_id
801    )
802    .fetch_all(&mut *tx)
803    .await?;
804    let (ids, texts) = changed_rows(
805        checkbox_texts
806            .into_iter()
807            .map(|checkbox_text| (checkbox_text.id, checkbox_text.text_html)),
808        |text_html| rewrite_html_hrefs(text_html, source_slug, copy_slug),
809    );
810    if !ids.is_empty() {
811        sqlx::query!(
812            "
813UPDATE course_custom_privacy_policy_checkbox_texts
814SET text_html = rewritten.text_html
815FROM UNNEST($1::uuid [], $2::text []) AS rewritten(id, text_html)
816WHERE course_custom_privacy_policy_checkbox_texts.id = rewritten.id;
817            ",
818            &ids,
819            &texts
820        )
821        .execute(&mut *tx)
822        .await?;
823    }
824
825    let privacy_links = sqlx::query!(
826        "
827SELECT id,
828  url
829FROM privacy_links
830WHERE course_id = $1
831  AND deleted_at IS NULL;
832        ",
833        course_id
834    )
835    .fetch_all(&mut *tx)
836    .await?;
837    let (ids, urls) = changed_rows(
838        privacy_links
839            .into_iter()
840            .map(|privacy_link| (privacy_link.id, privacy_link.url)),
841        |url| rewrite_course_path(url, source_slug, copy_slug),
842    );
843    if !ids.is_empty() {
844        sqlx::query!(
845            "
846UPDATE privacy_links
847SET url = rewritten.url
848FROM UNNEST($1::uuid [], $2::text []) AS rewritten(id, url)
849WHERE privacy_links.id = rewritten.id;
850            ",
851            &ids,
852            &urls
853        )
854        .execute(&mut *tx)
855        .await?;
856    }
857
858    Ok(())
859}
860
861/// Records each copied page's final state as its first history entry, as saving it in the CMS
862/// would. The chatbot syncer only indexes pages that have one.
863async fn insert_page_history_for_copies(
864    tx: &mut PgConnection,
865    page_ids: &[Uuid],
866    author_user_id: Uuid,
867) -> ModelResult<()> {
868    for page_id in page_ids {
869        let page = pages::get_page_with_exercises(&mut *tx, *page_id).await?;
870        page_history::insert(
871            &mut *tx,
872            PKeyPolicy::Generate,
873            page.page.id,
874            &page.page.title,
875            &PageHistoryContent {
876                content: page.page.content,
877                exercises: page.exercises,
878                exercise_slides: page.exercise_slides,
879                exercise_tasks: page.exercise_tasks,
880                peer_or_self_review_configs: page.peer_or_self_review_configs,
881                peer_or_self_review_questions: page.peer_or_self_review_questions,
882            },
883            HistoryChangeReason::PageSaved,
884            author_user_id,
885            None,
886        )
887        .await?;
888    }
889    Ok(())
890}
891
892/// Skips pages of deleted chapters: chapter deletion once left the pages in place.
893async fn copy_course_pages_and_return_contents(
894    tx: &mut PgConnection,
895    namespace_id: Uuid,
896    parent_course_id: Uuid,
897    target_clg_id: Uuid,
898    same_clg: bool,
899) -> ModelResult<HashMap<Uuid, Value>> {
900    // Copy course pages. At this point, exercise ids in content will point to old course's exercises.
901    let contents = sqlx::query!(
902        "
903WITH src AS (
904  SELECT p.*,
905    CASE
906      WHEN $4 THEN p.page_language_group_id
907      ELSE uuid_generate_v5($3, p.page_language_group_id::text)
908    END AS tgt_plg_id
909  FROM pages p
910  WHERE p.course_id = $2
911    AND p.deleted_at IS NULL
912    AND (
913      p.chapter_id IS NULL
914      OR EXISTS (
915        SELECT 1
916        FROM chapters c
917        WHERE c.id = uuid_generate_v5($1, p.chapter_id::text)
918      )
919    )
920),
921ins_plg AS (
922  INSERT INTO page_language_groups (id, course_language_group_id)
923  SELECT DISTINCT tgt_plg_id,
924    $3
925  FROM src
926  WHERE NOT $4
927    AND tgt_plg_id IS NOT NULL ON CONFLICT (id) DO NOTHING
928)
929INSERT INTO pages (
930    id,
931    course_id,
932    content,
933    url_path,
934    title,
935    chapter_id,
936    order_number,
937    copied_from,
938    content_search_language,
939    page_language_group_id,
940    hidden
941  )
942SELECT uuid_generate_v5($1, src.id::text),
943  $1,
944  src.content,
945  src.url_path,
946  src.title,
947  uuid_generate_v5($1, src.chapter_id::text),
948  src.order_number,
949  src.id,
950  src.content_search_language,
951  src.tgt_plg_id,
952  src.hidden
953FROM src
954RETURNING id,
955  content;
956        ",
957        namespace_id,
958        parent_course_id,
959        target_clg_id,
960        same_clg,
961    )
962    .fetch_all(tx)
963    .await?
964    .into_iter()
965    .map(|record| (record.id, record.content))
966    .collect();
967
968    Ok(contents)
969}
970
971async fn copy_exam_pages_and_return_contents(
972    tx: &mut PgConnection,
973    namespace_id: Uuid,
974    parent_exam_id: Uuid,
975) -> ModelResult<HashMap<Uuid, Value>> {
976    let contents = sqlx::query!(
977        "
978INSERT INTO pages (
979    id,
980    exam_id,
981    content,
982    url_path,
983    title,
984    chapter_id,
985    order_number,
986    copied_from,
987    content_search_language,
988    hidden
989  )
990SELECT uuid_generate_v5($1, id::text),
991  $1,
992  content,
993  url_path,
994  title,
995  NULL,
996  order_number,
997  id,
998  content_search_language,
999  hidden
1000FROM pages
1001WHERE (exam_id = $2)
1002  AND deleted_at IS NULL
1003RETURNING id,
1004  content;
1005        ",
1006        namespace_id,
1007        parent_exam_id
1008    )
1009    .fetch_all(tx)
1010    .await?
1011    .into_iter()
1012    .map(|record| (record.id, record.content))
1013    .collect();
1014
1015    Ok(contents)
1016}
1017
1018/// A chapter whose front page was not copied is left without one.
1019async fn set_chapter_front_pages(tx: &mut PgConnection, namespace_id: Uuid) -> ModelResult<()> {
1020    // Update front_page_id of chapters now that new pages exist.
1021    sqlx::query!(
1022        "
1023UPDATE chapters c
1024SET front_page_id = (
1025    SELECT p.id
1026    FROM pages p
1027    WHERE p.id = uuid_generate_v5(c.course_id, c.front_page_id::text)
1028  )
1029WHERE c.course_id = $1
1030  AND c.front_page_id IS NOT NULL;
1031            ",
1032        namespace_id,
1033    )
1034    .execute(&mut *tx)
1035    .await?;
1036
1037    Ok(())
1038}
1039
1040/// Returns the ids of the copied modules.
1041async fn copy_course_modules(
1042    tx: &mut PgConnection,
1043    new_course_id: Uuid,
1044    old_course_id: Uuid,
1045) -> ModelResult<HashSet<Uuid>> {
1046    let copied_ids = sqlx::query!(
1047        "
1048INSERT INTO course_modules (
1049    id,
1050    course_id,
1051    name,
1052    order_number,
1053    copied_from,
1054    automatic_completion,
1055    automatic_completion_number_of_exercises_attempted_treshold,
1056    automatic_completion_number_of_points_treshold,
1057    automatic_completion_requires_exam,
1058    certification_enabled,
1059    completion_registration_link_override,
1060    ects_credits,
1061    enable_registering_completion_to_uh_open_university,
1062    enable_credit_registration_via_suotar,
1063    uh_course_code
1064  )
1065SELECT uuid_generate_v5($1, id::text),
1066  $1,
1067  name,
1068  order_number,
1069  id,
1070  automatic_completion,
1071  automatic_completion_number_of_exercises_attempted_treshold,
1072  automatic_completion_number_of_points_treshold,
1073  automatic_completion_requires_exam,
1074  certification_enabled,
1075  completion_registration_link_override,
1076  ects_credits,
1077  enable_registering_completion_to_uh_open_university,
1078  enable_credit_registration_via_suotar,
1079  uh_course_code
1080FROM course_modules
1081WHERE course_id = $2
1082  AND deleted_at IS NULL
1083RETURNING id
1084        ",
1085        new_course_id,
1086        old_course_id,
1087    )
1088    .fetch_all(&mut *tx)
1089    .await?
1090    .into_iter()
1091    .map(|record| record.id)
1092    .collect();
1093    Ok(copied_ids)
1094}
1095
1096/// After this one `set_chapter_front_pages` needs to be called to get these to point to the correct front pages.
1097async fn copy_course_chapters(
1098    tx: &mut PgConnection,
1099    namespace_id: Uuid,
1100    parent_course_id: Uuid,
1101) -> ModelResult<()> {
1102    sqlx::query!(
1103        "
1104INSERT INTO chapters (
1105    id,
1106    name,
1107    course_id,
1108    chapter_number,
1109    front_page_id,
1110    opens_at,
1111    chapter_image_path,
1112    copied_from,
1113    course_module_id,
1114    color,
1115    deadline
1116  )
1117SELECT uuid_generate_v5($1, id::text),
1118  name,
1119  $1,
1120  chapter_number,
1121  front_page_id,
1122  opens_at,
1123  chapter_image_path,
1124  id,
1125  uuid_generate_v5($1, course_module_id::text),
1126  color,
1127  deadline
1128FROM chapters
1129WHERE (course_id = $2)
1130  AND deleted_at IS NULL;
1131    ",
1132        namespace_id,
1133        parent_course_id
1134    )
1135    .execute(&mut *tx)
1136    .await?;
1137
1138    Ok(())
1139}
1140
1141/// Copies the exercises of the copied pages and returns the copies' ids.
1142///
1143/// Exercises take their page's chapter, since `exercises.chapter_id` can be stale after a page
1144/// moved between chapters.
1145async fn copy_course_exercises(
1146    tx: &mut PgConnection,
1147    new_course_id: Uuid,
1148    src_course_id: Uuid,
1149    target_clg_id: Uuid,
1150    same_clg: bool,
1151) -> ModelResult<HashSet<Uuid>> {
1152    let copied_ids = sqlx::query!(
1153        r#"
1154WITH src AS (
1155  SELECT e.*,
1156    copied_page.chapter_id AS tgt_chapter_id,
1157    CASE
1158      WHEN $4 THEN e.exercise_language_group_id
1159      ELSE uuid_generate_v5($3, e.id::text)
1160    END AS tgt_elg_id
1161  FROM exercises e
1162    JOIN pages copied_page ON copied_page.id = uuid_generate_v5($1, e.page_id::text)
1163  WHERE e.course_id = $2
1164    AND e.deleted_at IS NULL
1165),
1166ins_elg AS (
1167  INSERT INTO exercise_language_groups (id, course_language_group_id)
1168  SELECT DISTINCT tgt_elg_id,
1169    $3
1170  FROM src
1171  WHERE NOT $4 ON CONFLICT (id) DO NOTHING
1172),
1173ins_exercises AS (
1174  INSERT INTO exercises (
1175      id,
1176      course_id,
1177      name,
1178      deadline,
1179      page_id,
1180      score_maximum,
1181      order_number,
1182      chapter_id,
1183      copied_from,
1184      exercise_language_group_id,
1185      max_tries_per_slide,
1186      limit_number_of_tries,
1187      needs_peer_review,
1188      use_course_default_peer_or_self_review_config,
1189      needs_self_review,
1190      teacher_reviews_answer_after_locking
1191    )
1192  SELECT uuid_generate_v5($1, src.id::text),
1193    $1,
1194    src.name,
1195    src.deadline,
1196    uuid_generate_v5($1, src.page_id::text),
1197    src.score_maximum,
1198    src.order_number,
1199    src.tgt_chapter_id,
1200    src.id,
1201    src.tgt_elg_id,
1202    src.max_tries_per_slide,
1203    src.limit_number_of_tries,
1204    src.needs_peer_review,
1205    src.use_course_default_peer_or_self_review_config,
1206    src.needs_self_review,
1207    src.teacher_reviews_answer_after_locking
1208  FROM src
1209  RETURNING id
1210)
1211SELECT id
1212FROM ins_exercises;
1213        "#,
1214        new_course_id,
1215        src_course_id,
1216        target_clg_id,
1217        same_clg,
1218    )
1219    .fetch_all(tx)
1220    .await?
1221    .into_iter()
1222    .map(|record| record.id)
1223    .collect();
1224
1225    Ok(copied_ids)
1226}
1227
1228/// Copies the exercises of the copied exam pages and returns the copies' ids.
1229async fn copy_exam_exercises(
1230    tx: &mut PgConnection,
1231    namespace_id: Uuid,
1232    parent_exam_id: Uuid,
1233) -> ModelResult<HashSet<Uuid>> {
1234    let copied_ids = sqlx::query!(
1235        "
1236INSERT INTO exercises (
1237    id,
1238    exam_id,
1239    name,
1240    deadline,
1241    page_id,
1242    score_maximum,
1243    order_number,
1244    chapter_id,
1245    copied_from,
1246    max_tries_per_slide,
1247    limit_number_of_tries,
1248    needs_peer_review,
1249    use_course_default_peer_or_self_review_config,
1250    needs_self_review,
1251    teacher_reviews_answer_after_locking
1252  )
1253SELECT uuid_generate_v5($1, e.id::text),
1254  $1,
1255  e.name,
1256  e.deadline,
1257  copied_page.id,
1258  e.score_maximum,
1259  e.order_number,
1260  NULL,
1261  e.id,
1262  e.max_tries_per_slide,
1263  e.limit_number_of_tries,
1264  e.needs_peer_review,
1265  e.use_course_default_peer_or_self_review_config,
1266  e.needs_self_review,
1267  e.teacher_reviews_answer_after_locking
1268FROM exercises e
1269  JOIN pages copied_page ON copied_page.id = uuid_generate_v5($1, e.page_id::text)
1270WHERE e.exam_id = $2
1271  AND e.deleted_at IS NULL
1272RETURNING id;
1273            ",
1274        namespace_id,
1275        parent_exam_id
1276    )
1277    .fetch_all(tx)
1278    .await?
1279    .into_iter()
1280    .map(|record| record.id)
1281    .collect();
1282
1283    Ok(copied_ids)
1284}
1285
1286async fn copy_exercise_slides(
1287    tx: &mut PgConnection,
1288    namespace_id: Uuid,
1289    parent_id: Uuid,
1290) -> ModelResult<()> {
1291    sqlx::query!(
1292        "
1293INSERT INTO exercise_slides (id, exercise_id, order_number)
1294SELECT uuid_generate_v5($1, s.id::text),
1295  copied_exercise.id,
1296  s.order_number
1297FROM exercise_slides s
1298  JOIN exercises e ON e.id = s.exercise_id
1299  JOIN exercises copied_exercise ON copied_exercise.id = uuid_generate_v5($1, s.exercise_id::text)
1300WHERE (e.course_id = $2 OR e.exam_id = $2)
1301  AND s.deleted_at IS NULL;
1302            ",
1303        namespace_id,
1304        parent_id
1305    )
1306    .execute(&mut *tx)
1307    .await?;
1308
1309    Ok(())
1310}
1311
1312async fn copy_exercise_tasks(
1313    tx: &mut PgConnection,
1314    namespace_id: Uuid,
1315    parent_id: Uuid,
1316) -> ModelResult<()> {
1317    sqlx::query!(
1318        "
1319INSERT INTO exercise_tasks (
1320    id,
1321    exercise_slide_id,
1322    exercise_type,
1323    assignment,
1324    private_spec,
1325    public_spec,
1326    model_solution_spec,
1327    order_number,
1328    copied_from
1329  )
1330SELECT uuid_generate_v5($1, t.id::text),
1331  copied_slide.id,
1332  t.exercise_type,
1333  t.assignment,
1334  t.private_spec,
1335  t.public_spec,
1336  t.model_solution_spec,
1337  t.order_number,
1338  t.id
1339FROM exercise_tasks t
1340  JOIN exercise_slides s ON s.id = t.exercise_slide_id
1341  JOIN exercises e ON e.id = s.exercise_id
1342  JOIN exercise_slides copied_slide ON copied_slide.id = uuid_generate_v5($1, t.exercise_slide_id::text)
1343WHERE (e.course_id = $2 OR e.exam_id = $2)
1344  AND t.deleted_at IS NULL;
1345    ",
1346        namespace_id,
1347        parent_id,
1348    )
1349    .execute(&mut *tx)
1350    .await?;
1351
1352    // The copied specs name the same stored files as the originals, so the copy has to declare
1353    // them too. Otherwise only the original task declares them, and editing or deleting the
1354    // original leaves the copy serving files the reaper is free to delete.
1355    sqlx::query!(
1356        "
1357INSERT INTO exercise_task_spec_files (exercise_task_id, file_upload_id, spec_kind)
1358SELECT copied_task.id,
1359  f.file_upload_id,
1360  f.spec_kind
1361FROM exercise_task_spec_files f
1362  JOIN exercise_tasks t ON t.id = f.exercise_task_id
1363  JOIN exercise_slides s ON s.id = t.exercise_slide_id
1364  JOIN exercises e ON e.id = s.exercise_id
1365  JOIN exercise_tasks copied_task ON copied_task.id = uuid_generate_v5($1, f.exercise_task_id::text)
1366WHERE (e.course_id = $2 OR e.exam_id = $2)
1367  AND f.deleted_at IS NULL;
1368    ",
1369        namespace_id,
1370        parent_id,
1371    )
1372    .execute(&mut *tx)
1373    .await?;
1374    Ok(())
1375}
1376
1377pub async fn copy_user_permissions(
1378    conn: &mut PgConnection,
1379    new_course_id: Uuid,
1380    old_course_id: Uuid,
1381    user_id: Uuid,
1382) -> ModelResult<()> {
1383    sqlx::query!(
1384        "
1385INSERT INTO roles (
1386    id,
1387    user_id,
1388    organization_id,
1389    course_id,
1390    role
1391  )
1392SELECT uuid_generate_v5($2, id::text),
1393  user_id,
1394  organization_id,
1395  $2,
1396  role
1397FROM roles
1398WHERE (course_id = $1)
1399AND NOT (user_id = $3)
1400AND deleted_at IS NULL;
1401    ",
1402        old_course_id,
1403        new_course_id,
1404        user_id
1405    )
1406    .execute(conn)
1407    .await?;
1408    Ok(())
1409}
1410
1411async fn copy_peer_or_self_review_configs(
1412    tx: &mut PgConnection,
1413    namespace_id: Uuid,
1414    parent_id: Uuid,
1415) -> ModelResult<()> {
1416    // Matched on the exercise's course, not the config's: older configs can carry the course_id of
1417    // the course their exercise block was pasted from.
1418    sqlx::query!(
1419        "
1420INSERT INTO peer_or_self_review_configs (
1421    id,
1422    course_id,
1423    exercise_id,
1424    peer_reviews_to_give,
1425    peer_reviews_to_receive,
1426    processing_strategy,
1427    accepting_threshold,
1428    manual_review_cutoff_in_days,
1429    points_are_all_or_nothing,
1430    review_instructions,
1431    reset_answer_if_zero_points_from_review
1432  )
1433SELECT uuid_generate_v5($1, posrc.id::text),
1434  $1,
1435  copied_exercise.id,
1436  posrc.peer_reviews_to_give,
1437  posrc.peer_reviews_to_receive,
1438  posrc.processing_strategy,
1439  posrc.accepting_threshold,
1440  posrc.manual_review_cutoff_in_days,
1441  posrc.points_are_all_or_nothing,
1442  posrc.review_instructions,
1443  posrc.reset_answer_if_zero_points_from_review
1444FROM peer_or_self_review_configs posrc
1445  LEFT JOIN exercises e ON (e.id = posrc.exercise_id)
1446  LEFT JOIN exercises copied_exercise ON (
1447    copied_exercise.id = uuid_generate_v5($1, posrc.exercise_id::text)
1448  )
1449WHERE posrc.deleted_at IS NULL
1450  AND (
1451    (
1452      posrc.exercise_id IS NULL
1453      AND posrc.course_id = $2
1454    )
1455    OR (
1456      e.course_id = $2
1457      AND copied_exercise.id IS NOT NULL
1458    )
1459  );
1460    ",
1461        namespace_id,
1462        parent_id,
1463    )
1464    .execute(&mut *tx)
1465    .await?;
1466    Ok(())
1467}
1468
1469async fn copy_peer_or_self_review_questions(
1470    tx: &mut PgConnection,
1471    namespace_id: Uuid,
1472    parent_id: Uuid,
1473) -> ModelResult<()> {
1474    sqlx::query!(
1475        "
1476INSERT INTO peer_or_self_review_questions (
1477    id,
1478    peer_or_self_review_config_id,
1479    order_number,
1480    question,
1481    question_type,
1482    answer_required,
1483    weight
1484  )
1485SELECT uuid_generate_v5($1, q.id::text),
1486  copied_config.id,
1487  q.order_number,
1488  q.question,
1489  q.question_type,
1490  q.answer_required,
1491  q.weight
1492FROM peer_or_self_review_questions q
1493  JOIN peer_or_self_review_configs posrc ON (posrc.id = q.peer_or_self_review_config_id)
1494  JOIN peer_or_self_review_configs copied_config ON (
1495    copied_config.id = uuid_generate_v5($1, q.peer_or_self_review_config_id::text)
1496  )
1497  LEFT JOIN exercises e ON (e.id = posrc.exercise_id)
1498WHERE q.deleted_at IS NULL
1499  AND (
1500    posrc.course_id = $2
1501    OR e.course_id = $2
1502  );
1503    ",
1504        namespace_id,
1505        parent_id,
1506    )
1507    .execute(&mut *tx)
1508    .await?;
1509    Ok(())
1510}
1511
1512async fn copy_material_references(
1513    tx: &mut PgConnection,
1514    namespace_id: Uuid,
1515    parent_id: Uuid,
1516) -> ModelResult<()> {
1517    // Copy material references
1518    sqlx::query!(
1519        "
1520INSERT INTO material_references (
1521    citation_key,
1522    course_id,
1523    id,
1524    reference
1525)
1526SELECT citation_key,
1527  $1,
1528  uuid_generate_v5($1, id::text),
1529  reference
1530FROM material_references
1531WHERE course_id = $2
1532AND deleted_at IS NULL;
1533    ",
1534        namespace_id,
1535        parent_id,
1536    )
1537    .execute(&mut *tx)
1538    .await?;
1539    Ok(())
1540}
1541
1542async fn copy_glossary_entries(
1543    tx: &mut PgConnection,
1544    new_course_id: Uuid,
1545    old_course_id: Uuid,
1546) -> ModelResult<()> {
1547    sqlx::query!(
1548        "
1549INSERT INTO glossary (
1550    id,
1551    course_id,
1552    term,
1553    definition
1554  )
1555SELECT uuid_generate_v5($1, id::text),
1556  $1,
1557  term,
1558  definition
1559FROM glossary
1560WHERE course_id = $2
1561  AND deleted_at IS NULL;
1562        ",
1563        new_course_id,
1564        old_course_id,
1565    )
1566    .execute(&mut *tx)
1567    .await?;
1568    Ok(())
1569}
1570
1571async fn copy_certificate_configurations_and_requirements(
1572    tx: &mut PgConnection,
1573    new_course_id: Uuid,
1574    old_course_id: Uuid,
1575) -> ModelResult<()> {
1576    sqlx::query!(
1577        "
1578INSERT INTO certificate_configurations (
1579    id,
1580    background_svg_file_upload_id,
1581    background_svg_path,
1582    certificate_date_font_size,
1583    certificate_date_text_anchor,
1584    certificate_date_text_color,
1585    certificate_date_x_pos,
1586    certificate_date_y_pos,
1587    certificate_grade_font_size,
1588    certificate_grade_text_anchor,
1589    certificate_grade_text_color,
1590    certificate_grade_x_pos,
1591    certificate_grade_y_pos,
1592    certificate_locale,
1593    certificate_owner_name_font_size,
1594    certificate_owner_name_text_anchor,
1595    certificate_owner_name_text_color,
1596    certificate_owner_name_x_pos,
1597    certificate_owner_name_y_pos,
1598    certificate_validate_url_font_size,
1599    certificate_validate_url_text_anchor,
1600    certificate_validate_url_text_color,
1601    certificate_validate_url_x_pos,
1602    certificate_validate_url_y_pos,
1603    overlay_svg_file_upload_id,
1604    overlay_svg_path,
1605    paper_size,
1606    render_certificate_grade
1607  )
1608SELECT uuid_generate_v5($1, id::text),
1609  background_svg_file_upload_id,
1610  background_svg_path,
1611  certificate_date_font_size,
1612  certificate_date_text_anchor,
1613  certificate_date_text_color,
1614  certificate_date_x_pos,
1615  certificate_date_y_pos,
1616  certificate_grade_font_size,
1617  certificate_grade_text_anchor,
1618  certificate_grade_text_color,
1619  certificate_grade_x_pos,
1620  certificate_grade_y_pos,
1621  certificate_locale,
1622  certificate_owner_name_font_size,
1623  certificate_owner_name_text_anchor,
1624  certificate_owner_name_text_color,
1625  certificate_owner_name_x_pos,
1626  certificate_owner_name_y_pos,
1627  certificate_validate_url_font_size,
1628  certificate_validate_url_text_anchor,
1629  certificate_validate_url_text_color,
1630  certificate_validate_url_x_pos,
1631  certificate_validate_url_y_pos,
1632  overlay_svg_file_upload_id,
1633  overlay_svg_path,
1634  paper_size,
1635  render_certificate_grade
1636FROM certificate_configurations
1637WHERE id IN (
1638    SELECT certificate_configuration_id
1639    FROM certificate_configuration_to_requirements cctr
1640      JOIN course_modules cm ON cctr.course_module_id = cm.id
1641    WHERE cm.course_id = $2
1642      AND cctr.deleted_at IS NULL
1643      AND cm.deleted_at IS NULL
1644  )
1645  AND deleted_at IS NULL;
1646        ",
1647        new_course_id,
1648        old_course_id
1649    )
1650    .execute(&mut *tx)
1651    .await?;
1652
1653    sqlx::query!(
1654        "
1655INSERT INTO certificate_configuration_to_requirements (
1656    id,
1657    certificate_configuration_id,
1658    course_module_id
1659  )
1660SELECT uuid_generate_v5($1, cctr.id::text),
1661  uuid_generate_v5($1, cctr.certificate_configuration_id::text),
1662  uuid_generate_v5($1, cctr.course_module_id::text)
1663FROM certificate_configuration_to_requirements cctr
1664  JOIN course_modules cm ON cctr.course_module_id = cm.id
1665  JOIN certificate_configurations cc ON cctr.certificate_configuration_id = cc.id
1666WHERE cm.course_id = $2
1667  AND cctr.deleted_at IS NULL
1668  AND cm.deleted_at IS NULL
1669  AND cc.deleted_at IS NULL;
1670        ",
1671        new_course_id,
1672        old_course_id
1673    )
1674    .execute(&mut *tx)
1675    .await?;
1676
1677    Ok(())
1678}
1679
1680/// Returns the ids of the copied configurations.
1681async fn copy_chatbot_configurations(
1682    tx: &mut PgConnection,
1683    new_course_id: Uuid,
1684    old_course_id: Uuid,
1685) -> ModelResult<HashSet<Uuid>> {
1686    let copied_ids = sqlx::query!(
1687        "
1688INSERT INTO chatbot_configurations (
1689    id,
1690    course_id,
1691    chatbot_name,
1692    initial_message,
1693    prompt,
1694    use_azure_search,
1695    maintain_azure_search_index,
1696    use_semantic_reranking,
1697    hide_citations,
1698    temperature,
1699    top_p,
1700    presence_penalty,
1701    frequency_penalty,
1702    max_output_tokens,
1703    daily_tokens_per_user,
1704    weekly_tokens_per_user,
1705    default_chatbot,
1706    enabled_to_students,
1707    model_id,
1708    enabled_tool_categories,
1709    verbosity,
1710    reasoning_effort,
1711    suggest_next_messages,
1712    initial_suggested_messages,
1713    publicly_accessible
1714  )
1715SELECT
1716  uuid_generate_v5($1, id::text),
1717  $1,
1718  chatbot_name,
1719  initial_message,
1720  prompt,
1721  use_azure_search,
1722  maintain_azure_search_index,
1723  use_semantic_reranking,
1724  hide_citations,
1725  temperature,
1726  top_p,
1727  presence_penalty,
1728  frequency_penalty,
1729  max_output_tokens,
1730  daily_tokens_per_user,
1731  weekly_tokens_per_user,
1732  default_chatbot,
1733  enabled_to_students,
1734  model_id,
1735  enabled_tool_categories,
1736  verbosity,
1737  reasoning_effort,
1738  suggest_next_messages,
1739  initial_suggested_messages,
1740  publicly_accessible
1741FROM chatbot_configurations
1742WHERE course_id = $2
1743  AND deleted_at IS NULL
1744RETURNING id;
1745        ",
1746        new_course_id,
1747        old_course_id
1748    )
1749    .fetch_all(&mut *tx)
1750    .await?
1751    .into_iter()
1752    .map(|record| record.id)
1753    .collect();
1754    Ok(copied_ids)
1755}
1756
1757async fn copy_cheater_thresholds(
1758    tx: &mut PgConnection,
1759    new_course_id: Uuid,
1760    old_course_id: Uuid,
1761) -> ModelResult<()> {
1762    sqlx::query!(
1763        "
1764INSERT INTO cheater_thresholds (id, course_module_id, duration_seconds)
1765SELECT uuid_generate_v5($1, ct.id::text),
1766  copied_module.id,
1767  ct.duration_seconds
1768FROM cheater_thresholds ct
1769  JOIN course_modules cm ON cm.id = ct.course_module_id
1770  JOIN course_modules copied_module ON copied_module.id = uuid_generate_v5($1, ct.course_module_id::text)
1771WHERE cm.course_id = $2
1772  AND ct.deleted_at IS NULL;
1773        ",
1774        new_course_id,
1775        old_course_id
1776    )
1777    .execute(&mut *tx)
1778    .await?;
1779    Ok(())
1780}
1781
1782async fn copy_course_custom_privacy_policy_checkbox_texts(
1783    tx: &mut PgConnection,
1784    new_course_id: Uuid,
1785    old_course_id: Uuid,
1786) -> ModelResult<()> {
1787    sqlx::query!(
1788        "
1789INSERT INTO course_custom_privacy_policy_checkbox_texts (id, course_id, text_slug, text_html)
1790SELECT uuid_generate_v5($1, id::text),
1791  $1,
1792  text_slug,
1793  text_html
1794FROM course_custom_privacy_policy_checkbox_texts
1795WHERE course_id = $2
1796  AND deleted_at IS NULL;
1797        ",
1798        new_course_id,
1799        old_course_id
1800    )
1801    .execute(&mut *tx)
1802    .await?;
1803    Ok(())
1804}
1805
1806async fn copy_exercise_repositories(
1807    tx: &mut PgConnection,
1808    new_course_id: Uuid,
1809    old_course_id: Uuid,
1810) -> ModelResult<()> {
1811    sqlx::query!(
1812        "
1813INSERT INTO exercise_repositories (
1814    id,
1815    course_id,
1816    url,
1817    deploy_key,
1818    public_key,
1819    STATUS,
1820    error_message
1821  )
1822SELECT uuid_generate_v5($1, id::text),
1823  $1,
1824  url,
1825  deploy_key,
1826  public_key,
1827  STATUS,
1828  error_message
1829FROM exercise_repositories
1830WHERE course_id = $2
1831  AND deleted_at IS NULL;
1832        ",
1833        new_course_id,
1834        old_course_id
1835    )
1836    .execute(&mut *tx)
1837    .await?;
1838    Ok(())
1839}
1840
1841async fn copy_partners_blocks(
1842    tx: &mut PgConnection,
1843    new_course_id: Uuid,
1844    old_course_id: Uuid,
1845) -> ModelResult<()> {
1846    sqlx::query!(
1847        "
1848INSERT INTO partners_blocks (id, course_id, content)
1849SELECT uuid_generate_v5($1, id::text),
1850  $1,
1851  content
1852FROM partners_blocks
1853WHERE course_id = $2
1854  AND deleted_at IS NULL;
1855        ",
1856        new_course_id,
1857        old_course_id
1858    )
1859    .execute(&mut *tx)
1860    .await?;
1861    Ok(())
1862}
1863
1864async fn copy_privacy_links(
1865    tx: &mut PgConnection,
1866    new_course_id: Uuid,
1867    old_course_id: Uuid,
1868) -> ModelResult<()> {
1869    sqlx::query!(
1870        "
1871INSERT INTO privacy_links (id, course_id, url, title)
1872SELECT uuid_generate_v5($1, id::text),
1873  $1,
1874  url,
1875  title
1876FROM privacy_links
1877WHERE course_id = $2
1878  AND deleted_at IS NULL;
1879        ",
1880        new_course_id,
1881        old_course_id
1882    )
1883    .execute(&mut *tx)
1884    .await?;
1885    Ok(())
1886}
1887
1888/// Returns the ids of the copied questions. The form content still names the source questions
1889/// until it goes through `ContentRewrite`.
1890async fn copy_research_consent_forms_and_questions(
1891    tx: &mut PgConnection,
1892    new_course_id: Uuid,
1893    old_course_id: Uuid,
1894) -> ModelResult<HashSet<Uuid>> {
1895    sqlx::query!(
1896        "
1897INSERT INTO course_specific_research_consent_forms (id, course_id, content)
1898SELECT uuid_generate_v5($1, id::text),
1899  $1,
1900  content
1901FROM course_specific_research_consent_forms
1902WHERE course_id = $2
1903  AND deleted_at IS NULL;
1904        ",
1905        new_course_id,
1906        old_course_id
1907    )
1908    .execute(&mut *tx)
1909    .await?;
1910
1911    let copied_ids = sqlx::query!(
1912        "
1913INSERT INTO course_specific_consent_form_questions (
1914    id,
1915    course_id,
1916    research_consent_form_id,
1917    question
1918  )
1919SELECT uuid_generate_v5($1, q.id::text),
1920  $1,
1921  copied_form.id,
1922  q.question
1923FROM course_specific_consent_form_questions q
1924  JOIN course_specific_research_consent_forms copied_form ON (
1925    copied_form.id = uuid_generate_v5($1, q.research_consent_form_id::text)
1926  )
1927WHERE q.course_id = $2
1928  AND q.deleted_at IS NULL
1929RETURNING id;
1930        ",
1931        new_course_id,
1932        old_course_id
1933    )
1934    .fetch_all(&mut *tx)
1935    .await?
1936    .into_iter()
1937    .map(|record| record.id)
1938    .collect();
1939
1940    Ok(copied_ids)
1941}
1942
1943async fn copy_email_templates(
1944    tx: &mut PgConnection,
1945    new_course_id: Uuid,
1946    old_course_id: Uuid,
1947) -> ModelResult<()> {
1948    sqlx::query!(
1949        "
1950INSERT INTO email_templates (
1951    id,
1952    course_id,
1953    content,
1954    subject,
1955    exercise_completions_threshold,
1956    points_threshold,
1957    language,
1958    email_template_type
1959  )
1960SELECT uuid_generate_v5($1, id::text),
1961  $1,
1962  content,
1963  subject,
1964  exercise_completions_threshold,
1965  points_threshold,
1966  language,
1967  email_template_type
1968FROM email_templates
1969WHERE course_id = $2
1970  AND deleted_at IS NULL;
1971        ",
1972        new_course_id,
1973        old_course_id
1974    )
1975    .execute(&mut *tx)
1976    .await?;
1977    Ok(())
1978}
1979
1980/// Copies the giveaways without their codes and returns the copies' ids. Must run after the
1981/// modules and the research consent questions are copied.
1982async fn copy_code_giveaways(
1983    tx: &mut PgConnection,
1984    new_course_id: Uuid,
1985    old_course_id: Uuid,
1986) -> ModelResult<HashSet<Uuid>> {
1987    // A required consent question that was deleted cannot be answered, so the source giveaway can
1988    // never hand out codes; dropping the requirement would open the copy to everyone instead.
1989    let copied_ids = sqlx::query!(
1990        "
1991INSERT INTO code_giveaways (
1992    id,
1993    course_id,
1994    course_module_id,
1995    enabled,
1996    require_course_specific_consent_form_question_id,
1997    name
1998  )
1999SELECT uuid_generate_v5($1, cg.id::text),
2000  $1,
2001  copied_module.id,
2002  cg.enabled
2003  AND (
2004    cg.require_course_specific_consent_form_question_id IS NULL
2005    OR copied_question.id IS NOT NULL
2006  ),
2007  copied_question.id,
2008  cg.name
2009FROM code_giveaways cg
2010  LEFT JOIN course_modules copied_module ON (
2011    copied_module.id = uuid_generate_v5($1, cg.course_module_id::text)
2012  )
2013  LEFT JOIN course_specific_consent_form_questions copied_question ON (
2014    copied_question.id = uuid_generate_v5(
2015      $1,
2016      cg.require_course_specific_consent_form_question_id::text
2017    )
2018  )
2019WHERE cg.course_id = $2
2020  AND cg.deleted_at IS NULL
2021RETURNING id;
2022        ",
2023        new_course_id,
2024        old_course_id
2025    )
2026    .fetch_all(&mut *tx)
2027    .await?
2028    .into_iter()
2029    .map(|record| record.id)
2030    .collect();
2031    Ok(copied_ids)
2032}
2033
2034/// The copies share the stored audio files with the source pages.
2035async fn copy_page_audio_files(
2036    tx: &mut PgConnection,
2037    namespace_id: Uuid,
2038    parent_id: Uuid,
2039) -> ModelResult<()> {
2040    sqlx::query!(
2041        "
2042INSERT INTO page_audio_files (id, page_id, path, mime_type)
2043SELECT uuid_generate_v5($1, a.id::text),
2044  copied_page.id,
2045  a.path,
2046  a.mime_type
2047FROM page_audio_files a
2048  JOIN pages p ON p.id = a.page_id
2049  JOIN pages copied_page ON copied_page.id = uuid_generate_v5($1, a.page_id::text)
2050WHERE (p.course_id = $2 OR p.exam_id = $2)
2051  AND a.deleted_at IS NULL;
2052        ",
2053        namespace_id,
2054        parent_id
2055    )
2056    .execute(&mut *tx)
2057    .await?;
2058    Ok(())
2059}
2060
2061#[cfg(test)]
2062mod tests {
2063    use super::*;
2064    use crate::{exercise_tasks::ExerciseTask, pages::Page, test_helper::*};
2065    use pretty_assertions::assert_eq;
2066
2067    #[tokio::test]
2068    async fn elg_preserved_when_same_course_language_group() {
2069        insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
2070                     :chapter, :page, :exercise);
2071        let original_ex = crate::exercises::get_by_id(tx.as_mut(), exercise)
2072            .await
2073            .unwrap();
2074
2075        /* copy into THE SAME CLG via same_language_group = true */
2076        let new_meta = create_new_course(org, "fi-FI".into());
2077        let copied_course = copy_course(tx.as_mut(), course, &new_meta, true, user)
2078            .await
2079            .unwrap();
2080
2081        let copied_ex = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
2082            .await
2083            .unwrap()
2084            .pop()
2085            .unwrap();
2086
2087        assert_eq!(
2088            original_ex.exercise_language_group_id, copied_ex.exercise_language_group_id,
2089            "ELG must stay identical when CLG is unchanged"
2090        );
2091    }
2092
2093    /// 2.  When we copy to a *different* CLG twice **with the same target id**,
2094    ///     every exercise must get the SAME deterministic ELG each time.
2095    #[tokio::test]
2096    async fn elg_deterministic_when_reusing_target_clg() {
2097        insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
2098                     :chapter, :page, exercise: _e);
2099
2100        // Pre-create a brand-new CLG that both copies will use
2101        let reusable_clg =
2102            course_language_groups::insert(tx.as_mut(), PKeyPolicy::Generate, "reusable-clg")
2103                .await
2104                .unwrap();
2105
2106        let meta1 = create_new_course(org, "en-US".into());
2107        let copy1 =
2108            copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta1, user)
2109                .await
2110                .unwrap();
2111
2112        let meta2 = {
2113            let mut nc = create_new_course(org, "pt-BR".into());
2114            nc.slug = "copied-course-2".into(); // ensure uniqueness
2115            nc
2116        };
2117        let copy2 =
2118            copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta2, user)
2119                .await
2120                .unwrap();
2121
2122        let ex1 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy1.id)
2123            .await
2124            .unwrap()
2125            .pop()
2126            .unwrap();
2127        let ex2 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy2.id)
2128            .await
2129            .unwrap()
2130            .pop()
2131            .unwrap();
2132
2133        assert_ne!(ex1.course_id, ex2.course_id); // different copies
2134        assert_eq!(
2135            ex1.exercise_language_group_id, ex2.exercise_language_group_id,
2136            "ELG must be deterministic for the same (target CLG, src exercise)"
2137        );
2138    }
2139
2140    #[tokio::test]
2141    async fn copies_course_as_different_course_language_group() {
2142        insert_data!(:tx, :user, :org, :course);
2143        let course = crate::courses::get_course(tx.as_mut(), course)
2144            .await
2145            .unwrap();
2146        let new_course = create_new_course(org, "en-US".into());
2147        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, false, user)
2148            .await
2149            .unwrap();
2150        assert_ne!(
2151            course.course_language_group_id,
2152            copied_course.course_language_group_id
2153        );
2154    }
2155
2156    #[tokio::test]
2157    async fn copies_course_as_same_course_language_group() {
2158        insert_data!(:tx, :user, :org, :course);
2159        let course = crate::courses::get_course(tx.as_mut(), course)
2160            .await
2161            .unwrap();
2162        let new_course = create_new_course(org, "fi-FI".into());
2163        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2164            .await
2165            .unwrap();
2166        assert_eq!(
2167            course.course_language_group_id,
2168            copied_course.course_language_group_id
2169        );
2170    }
2171
2172    #[tokio::test]
2173    async fn copies_course_instances() {
2174        insert_data!(:tx, :user, :org, :course, instance: _instance);
2175        let course = crate::courses::get_course(tx.as_mut(), course)
2176            .await
2177            .unwrap();
2178        let new_course = create_new_course(org, "en-GB".into());
2179        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2180            .await
2181            .unwrap();
2182        let copied_instances =
2183            crate::course_instances::get_course_instances_for_course(tx.as_mut(), copied_course.id)
2184                .await
2185                .unwrap();
2186        assert_eq!(copied_instances.len(), 1);
2187    }
2188
2189    #[tokio::test]
2190    async fn copies_course_modules() {
2191        insert_data!(:tx, :user, :org, :course);
2192        let course = crate::courses::get_course(tx.as_mut(), course)
2193            .await
2194            .unwrap();
2195        let new_course = create_new_course(org, "pt-BR".into());
2196        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2197            .await
2198            .unwrap();
2199
2200        let original_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course.id)
2201            .await
2202            .unwrap();
2203        let copied_modules = crate::course_modules::get_by_course_id(tx.as_mut(), copied_course.id)
2204            .await
2205            .unwrap();
2206        assert_eq!(
2207            original_modules.first().unwrap().id,
2208            copied_modules.first().unwrap().copied_from.unwrap(),
2209        )
2210    }
2211
2212    async fn insert_certificate_configuration(conn: &mut PgConnection) -> Uuid {
2213        let background_svg_file_upload_id = crate::file_uploads::insert(
2214            &mut *conn,
2215            "background.svg",
2216            "certificates/background.svg",
2217            "image/svg+xml",
2218            None,
2219            None,
2220        )
2221        .await
2222        .unwrap();
2223        let configuration = crate::certificate_configurations::DatabaseCertificateConfiguration {
2224            id: Uuid::new_v4(),
2225            certificate_owner_name_y_pos: None,
2226            certificate_owner_name_x_pos: None,
2227            certificate_owner_name_font_size: None,
2228            certificate_owner_name_text_color: None,
2229            certificate_owner_name_text_anchor: None,
2230            certificate_validate_url_y_pos: None,
2231            certificate_validate_url_x_pos: None,
2232            certificate_validate_url_font_size: None,
2233            certificate_validate_url_text_color: None,
2234            certificate_validate_url_text_anchor: None,
2235            certificate_date_y_pos: None,
2236            certificate_date_x_pos: None,
2237            certificate_date_font_size: None,
2238            certificate_date_text_color: None,
2239            certificate_date_text_anchor: None,
2240            certificate_locale: None,
2241            paper_size: None,
2242            background_svg_path: "certificates/background.svg".to_string(),
2243            background_svg_file_upload_id,
2244            overlay_svg_path: None,
2245            overlay_svg_file_upload_id: None,
2246            render_certificate_grade: false,
2247            certificate_grade_y_pos: None,
2248            certificate_grade_x_pos: None,
2249            certificate_grade_font_size: None,
2250            certificate_grade_text_color: None,
2251            certificate_grade_text_anchor: None,
2252        };
2253        crate::certificate_configurations::insert(conn, &configuration)
2254            .await
2255            .unwrap()
2256            .id
2257    }
2258
2259    async fn certificate_configurations_for_course(
2260        conn: &mut PgConnection,
2261        course_id: Uuid,
2262    ) -> Vec<crate::certificate_configurations::CertificateConfigurationAndRequirements> {
2263        crate::certificate_configurations::get_default_certificate_configurations_and_requirements_by_course(
2264            conn, course_id,
2265        )
2266        .await
2267        .unwrap()
2268    }
2269
2270    #[tokio::test]
2271    async fn copies_certificate_configurations() {
2272        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
2273        let configuration = insert_certificate_configuration(tx.as_mut()).await;
2274        crate::certificate_configuration_to_requirements::insert(
2275            tx.as_mut(),
2276            configuration,
2277            Some(course_module.id),
2278        )
2279        .await
2280        .unwrap();
2281
2282        let new_course = create_new_course(org, "en-NZ".into());
2283        let copied_course = copy_course(tx.as_mut(), course, &new_course, true, user)
2284            .await
2285            .unwrap();
2286
2287        let copied = certificate_configurations_for_course(tx.as_mut(), copied_course.id).await;
2288        assert_eq!(copied.len(), 1);
2289        let required_module_id = *copied[0]
2290            .requirements
2291            .course_module_ids
2292            .first()
2293            .expect("the copied configuration should require a module");
2294        let required_module = crate::course_modules::get_by_id(tx.as_mut(), required_module_id)
2295            .await
2296            .unwrap();
2297        assert_eq!(required_module.course_id, copied_course.id);
2298        assert_eq!(required_module.copied_from, Some(course_module.id));
2299    }
2300
2301    /// Older data can hold requirements pointing at a deleted configuration.
2302    #[tokio::test]
2303    async fn skips_requirements_of_deleted_certificate_configurations() {
2304        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
2305        let configuration = insert_certificate_configuration(tx.as_mut()).await;
2306        crate::certificate_configurations::delete(tx.as_mut(), configuration)
2307            .await
2308            .unwrap();
2309        // `delete` clears requirements, so the link has to come after it.
2310        crate::certificate_configuration_to_requirements::insert(
2311            tx.as_mut(),
2312            configuration,
2313            Some(course_module.id),
2314        )
2315        .await
2316        .unwrap();
2317
2318        let new_course = create_new_course(org, "en-IE".into());
2319        let copied_course = copy_course(tx.as_mut(), course, &new_course, true, user)
2320            .await
2321            .unwrap();
2322
2323        assert!(
2324            certificate_configurations_for_course(tx.as_mut(), copied_course.id)
2325                .await
2326                .is_empty()
2327        );
2328    }
2329
2330    #[tokio::test]
2331    async fn copies_course_chapters() {
2332        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter);
2333        let course = crate::courses::get_course(tx.as_mut(), course)
2334            .await
2335            .unwrap();
2336        let new_course = create_new_course(org, "sv-SV".into());
2337        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2338            .await
2339            .unwrap();
2340        let copied_chapters = crate::chapters::get_course_chapters(tx.as_mut(), copied_course.id)
2341            .await
2342            .unwrap();
2343        assert_eq!(copied_chapters.len(), 1);
2344        assert_eq!(copied_chapters.first().unwrap().copied_from, Some(chapter));
2345    }
2346
2347    #[tokio::test]
2348    async fn updates_chapter_front_pages() {
2349        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, chapter: _chapter);
2350        let course = crate::courses::get_course(tx.as_mut(), course)
2351            .await
2352            .unwrap();
2353        let new_course = create_new_course(org, "fr-CA".into());
2354        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2355            .await
2356            .unwrap();
2357        let copied_chapters = crate::chapters::get_course_chapters(tx.as_mut(), copied_course.id)
2358            .await
2359            .unwrap();
2360        let copied_chapter = copied_chapters.first().unwrap();
2361        let copied_chapter_front_page =
2362            crate::pages::get_page(tx.as_mut(), copied_chapter.front_page_id.unwrap())
2363                .await
2364                .unwrap();
2365        assert_eq!(copied_chapter_front_page.course_id, Some(copied_course.id));
2366    }
2367
2368    #[tokio::test]
2369    async fn copies_course_pages() {
2370        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, page: _page);
2371        let course = crate::courses::get_course(tx.as_mut(), course)
2372            .await
2373            .unwrap();
2374        let new_course = create_new_course(org, "es-US".into());
2375        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2376            .await
2377            .unwrap();
2378        let mut original_pages_by_id: HashMap<Uuid, Page> =
2379            crate::pages::get_all_by_course_id_and_visibility(
2380                tx.as_mut(),
2381                course.id,
2382                crate::pages::PageVisibility::Any,
2383            )
2384            .await
2385            .unwrap()
2386            .into_iter()
2387            .map(|page| (page.id, page))
2388            .collect();
2389        assert_eq!(original_pages_by_id.len(), 3);
2390        let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
2391            tx.as_mut(),
2392            copied_course.id,
2393            crate::pages::PageVisibility::Any,
2394        )
2395        .await
2396        .unwrap();
2397        assert_eq!(copied_pages.len(), 3);
2398        copied_pages.into_iter().for_each(|copied_page| {
2399            assert!(
2400                original_pages_by_id
2401                    .remove(&copied_page.copied_from.unwrap())
2402                    .is_some()
2403            );
2404        });
2405        assert!(original_pages_by_id.is_empty());
2406    }
2407
2408    #[tokio::test]
2409    async fn updates_course_slugs_in_internal_links_in_pages_contents() {
2410        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page);
2411        let course = crate::courses::get_course(tx.as_mut(), course)
2412            .await
2413            .unwrap();
2414        crate::pages::update_page_content(
2415            tx.as_mut(),
2416            page,
2417            &serde_json::json!([{
2418                "name": "core/paragraph",
2419                "isValid": true,
2420                "clientId": "b2ecb473-38cc-4df1-84f7-45709cc63e95",
2421                "attributes": {
2422                    "content": format!("Internal link <a href=\"http://project-331.local/org/uh-cs/courses/{slug2}\">http://project-331.local/org/uh-cs/courses/{slug1}</a>", slug2 = course.slug, slug1 = course.slug),
2423                    "dropCap":false
2424                },
2425                "innerBlocks": []
2426            }]),
2427        )
2428        .await.unwrap();
2429
2430        let new_course = create_new_course(org, "fi-FI".into());
2431        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2432            .await
2433            .unwrap();
2434
2435        let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
2436            tx.as_mut(),
2437            copied_course.id,
2438            crate::pages::PageVisibility::Any,
2439        )
2440        .await
2441        .unwrap();
2442        let copied_page = copied_pages
2443            .into_iter()
2444            .find(|copied_page| copied_page.copied_from == Some(page))
2445            .unwrap();
2446        let copied_content_in_page = copied_page.content[0]["attributes"]["content"]
2447            .as_str()
2448            .unwrap();
2449        let content_with_updated_course_slug = format!(
2450            "Internal link <a href=\"http://project-331.local/org/uh-cs/courses/copied-course\">http://project-331.local/org/uh-cs/courses/{}</a>",
2451            course.slug
2452        );
2453        assert_eq!(copied_content_in_page, content_with_updated_course_slug);
2454    }
2455
2456    #[tokio::test]
2457    async fn updates_exercise_id_in_content() {
2458        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise);
2459        let course = crate::courses::get_course(tx.as_mut(), course)
2460            .await
2461            .unwrap();
2462        crate::pages::update_page_content(
2463            tx.as_mut(),
2464            page,
2465            &serde_json::json!([{
2466                "name": "moocfi/exercise",
2467                "isValid": true,
2468                "clientId": "b2ecb473-38cc-4df1-84f7-06709cc63e95",
2469                "attributes": {
2470                    "id": exercise,
2471                    "name": "Exercise"
2472                },
2473                "innerBlocks": []
2474            }]),
2475        )
2476        .await
2477        .unwrap();
2478        let new_course = create_new_course(org, "es-MX".into());
2479        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2480            .await
2481            .unwrap();
2482        let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
2483            tx.as_mut(),
2484            copied_course.id,
2485            crate::pages::PageVisibility::Any,
2486        )
2487        .await
2488        .unwrap();
2489        let copied_page = copied_pages
2490            .into_iter()
2491            .find(|copied_page| copied_page.copied_from == Some(page))
2492            .unwrap();
2493        let copied_exercise_id_in_content =
2494            Uuid::parse_str(copied_page.content[0]["attributes"]["id"].as_str().unwrap()).unwrap();
2495        let copied_exercise =
2496            crate::exercises::get_by_id(tx.as_mut(), copied_exercise_id_in_content)
2497                .await
2498                .unwrap();
2499        assert_eq!(copied_exercise.course_id.unwrap(), copied_course.id);
2500    }
2501
2502    #[tokio::test]
2503    async fn copies_exercises_tasks_and_slides() {
2504        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise, :slide, :task);
2505        let course = crate::courses::get_course(tx.as_mut(), course)
2506            .await
2507            .unwrap();
2508        let new_course = create_new_course(org, "fi-SV".into());
2509        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2510            .await
2511            .unwrap();
2512        let copied_exercises =
2513            crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
2514                .await
2515                .unwrap();
2516        assert_eq!(copied_exercises.len(), 1);
2517        let copied_exercise = copied_exercises.first().unwrap();
2518        assert_eq!(copied_exercise.copied_from, Some(exercise));
2519        let original_exercise = crate::exercises::get_by_id(tx.as_mut(), exercise)
2520            .await
2521            .unwrap();
2522        assert_eq!(
2523            copied_exercise.max_tries_per_slide,
2524            original_exercise.max_tries_per_slide
2525        );
2526        assert_eq!(
2527            copied_exercise.limit_number_of_tries,
2528            original_exercise.limit_number_of_tries
2529        );
2530        assert_eq!(
2531            copied_exercise.needs_peer_review,
2532            original_exercise.needs_peer_review
2533        );
2534        assert_eq!(
2535            copied_exercise.use_course_default_peer_or_self_review_config,
2536            original_exercise.use_course_default_peer_or_self_review_config
2537        );
2538        let copied_slides = crate::exercise_slides::get_exercise_slides_by_exercise_id(
2539            tx.as_mut(),
2540            copied_exercise.id,
2541        )
2542        .await
2543        .unwrap();
2544        assert_eq!(copied_slides.len(), 1);
2545        let copied_slide = copied_slides.first().unwrap();
2546        let copied_tasks: Vec<ExerciseTask> =
2547            crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_id(
2548                tx.as_mut(),
2549                &copied_slide.id,
2550            )
2551            .await
2552            .unwrap();
2553        assert_eq!(copied_tasks.len(), 1);
2554        let copied_task = copied_tasks.first().unwrap();
2555        assert_eq!(copied_task.copied_from, Some(task));
2556
2557        let original_course_chapters = crate::chapters::get_course_chapters(tx.as_mut(), course.id)
2558            .await
2559            .unwrap();
2560        for original_chapter in original_course_chapters {
2561            for copied_exercise in &copied_exercises {
2562                assert_ne!(original_chapter.id, copied_exercise.id);
2563            }
2564        }
2565    }
2566
2567    /// A copied course's specs name the same stored files as the originals, so the copy has to
2568    /// declare them too. Without this the file is declared only by the original task, and editing
2569    /// or deleting the original hands the copy's files to the abandoned-upload reaper.
2570    #[tokio::test]
2571    async fn copies_the_files_the_specs_declare() {
2572        insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
2573                     :chapter, :page, :exercise, :slide, task: task);
2574        let file_upload_id = crate::file_uploads::insert(
2575            tx.as_mut(),
2576            "teacher-example.pdf",
2577            "declaring-service/teacher-example.pdf",
2578            "application/pdf",
2579            None,
2580            None,
2581        )
2582        .await
2583        .unwrap();
2584        crate::exercise_task_spec_files::replace_for_exercise_task(
2585            tx.as_mut(),
2586            task,
2587            crate::exercise_task_spec_files::SpecKind::Private,
2588            &[file_upload_id],
2589        )
2590        .await
2591        .unwrap();
2592
2593        let new_meta = create_new_course(org, "fi-FI".into());
2594        let copied_course = copy_course(tx.as_mut(), course, &new_meta, false, user)
2595            .await
2596            .unwrap();
2597
2598        let copied_exercise =
2599            crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
2600                .await
2601                .unwrap()
2602                .pop()
2603                .unwrap();
2604        let copied_slides = crate::exercise_slides::get_exercise_slides_by_exercise_ids(
2605            tx.as_mut(),
2606            &[copied_exercise.id],
2607        )
2608        .await
2609        .unwrap();
2610        let copied_tasks = crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_ids(
2611            tx.as_mut(),
2612            &copied_slides
2613                .iter()
2614                .map(|slide| slide.id)
2615                .collect::<Vec<_>>(),
2616        )
2617        .await
2618        .unwrap();
2619        let copied_task = copied_tasks.first().expect("the copy has a task");
2620
2621        let declared = crate::exercise_task_spec_files::get_for_exercise_task(
2622            tx.as_mut(),
2623            copied_task.id,
2624            crate::exercise_task_spec_files::SpecKind::Private,
2625        )
2626        .await
2627        .unwrap();
2628        assert_eq!(declared, vec![file_upload_id]);
2629    }
2630
2631    fn create_new_course(organization_id: Uuid, language_code: String) -> NewCourse {
2632        NewCourse {
2633            name: "Copied course".to_string(),
2634            slug: "copied-course".to_string(),
2635            organization_id,
2636            language_code,
2637            teacher_in_charge_name: "Teacher".to_string(),
2638            teacher_in_charge_email: "teacher@example.com".to_string(),
2639            description: "".to_string(),
2640            is_draft: true,
2641            is_test_mode: false,
2642            is_unlisted: false,
2643            copy_user_permissions: false,
2644            is_joinable_by_code_only: false,
2645            join_code: None,
2646            ask_marketing_consent: false,
2647            flagged_answers_threshold: Some(3),
2648            can_add_chatbot: false,
2649        }
2650    }
2651}