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            crate::entity_history::HistoryAuthor::user(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    completions_can_be_registered_to_sisu,
1060    completion_registration_link_override,
1061    ects_credits,
1062    uh_course_code
1063  )
1064SELECT uuid_generate_v5($1, id::text),
1065  $1,
1066  name,
1067  order_number,
1068  id,
1069  automatic_completion,
1070  automatic_completion_number_of_exercises_attempted_treshold,
1071  automatic_completion_number_of_points_treshold,
1072  automatic_completion_requires_exam,
1073  certification_enabled,
1074  completions_can_be_registered_to_sisu,
1075  completion_registration_link_override,
1076  ects_credits,
1077  uh_course_code
1078FROM course_modules
1079WHERE course_id = $2
1080  AND deleted_at IS NULL
1081RETURNING id
1082        ",
1083        new_course_id,
1084        old_course_id,
1085    )
1086    .fetch_all(&mut *tx)
1087    .await?
1088    .into_iter()
1089    .map(|record| record.id)
1090    .collect();
1091    Ok(copied_ids)
1092}
1093
1094/// After this one `set_chapter_front_pages` needs to be called to get these to point to the correct front pages.
1095async fn copy_course_chapters(
1096    tx: &mut PgConnection,
1097    namespace_id: Uuid,
1098    parent_course_id: Uuid,
1099) -> ModelResult<()> {
1100    sqlx::query!(
1101        "
1102INSERT INTO chapters (
1103    id,
1104    name,
1105    course_id,
1106    chapter_number,
1107    front_page_id,
1108    opens_at,
1109    chapter_image_path,
1110    copied_from,
1111    course_module_id,
1112    color,
1113    deadline
1114  )
1115SELECT uuid_generate_v5($1, id::text),
1116  name,
1117  $1,
1118  chapter_number,
1119  front_page_id,
1120  opens_at,
1121  chapter_image_path,
1122  id,
1123  uuid_generate_v5($1, course_module_id::text),
1124  color,
1125  deadline
1126FROM chapters
1127WHERE (course_id = $2)
1128  AND deleted_at IS NULL;
1129    ",
1130        namespace_id,
1131        parent_course_id
1132    )
1133    .execute(&mut *tx)
1134    .await?;
1135
1136    Ok(())
1137}
1138
1139/// Copies the exercises of the copied pages and returns the copies' ids.
1140///
1141/// Exercises take their page's chapter, since `exercises.chapter_id` can be stale after a page
1142/// moved between chapters.
1143async fn copy_course_exercises(
1144    tx: &mut PgConnection,
1145    new_course_id: Uuid,
1146    src_course_id: Uuid,
1147    target_clg_id: Uuid,
1148    same_clg: bool,
1149) -> ModelResult<HashSet<Uuid>> {
1150    let copied_ids = sqlx::query!(
1151        r#"
1152WITH src AS (
1153  SELECT e.*,
1154    copied_page.chapter_id AS tgt_chapter_id,
1155    CASE
1156      WHEN $4 THEN e.exercise_language_group_id
1157      ELSE uuid_generate_v5($3, e.id::text)
1158    END AS tgt_elg_id
1159  FROM exercises e
1160    JOIN pages copied_page ON copied_page.id = uuid_generate_v5($1, e.page_id::text)
1161  WHERE e.course_id = $2
1162    AND e.deleted_at IS NULL
1163),
1164ins_elg AS (
1165  INSERT INTO exercise_language_groups (id, course_language_group_id)
1166  SELECT DISTINCT tgt_elg_id,
1167    $3
1168  FROM src
1169  WHERE NOT $4 ON CONFLICT (id) DO NOTHING
1170),
1171ins_exercises AS (
1172  INSERT INTO exercises (
1173      id,
1174      course_id,
1175      name,
1176      deadline,
1177      page_id,
1178      score_maximum,
1179      order_number,
1180      chapter_id,
1181      copied_from,
1182      exercise_language_group_id,
1183      max_tries_per_slide,
1184      limit_number_of_tries,
1185      needs_peer_review,
1186      use_course_default_peer_or_self_review_config,
1187      needs_self_review,
1188      teacher_reviews_answer_after_locking
1189    )
1190  SELECT uuid_generate_v5($1, src.id::text),
1191    $1,
1192    src.name,
1193    src.deadline,
1194    uuid_generate_v5($1, src.page_id::text),
1195    src.score_maximum,
1196    src.order_number,
1197    src.tgt_chapter_id,
1198    src.id,
1199    src.tgt_elg_id,
1200    src.max_tries_per_slide,
1201    src.limit_number_of_tries,
1202    src.needs_peer_review,
1203    src.use_course_default_peer_or_self_review_config,
1204    src.needs_self_review,
1205    src.teacher_reviews_answer_after_locking
1206  FROM src
1207  RETURNING id
1208)
1209SELECT id
1210FROM ins_exercises;
1211        "#,
1212        new_course_id,
1213        src_course_id,
1214        target_clg_id,
1215        same_clg,
1216    )
1217    .fetch_all(tx)
1218    .await?
1219    .into_iter()
1220    .map(|record| record.id)
1221    .collect();
1222
1223    Ok(copied_ids)
1224}
1225
1226/// Copies the exercises of the copied exam pages and returns the copies' ids.
1227async fn copy_exam_exercises(
1228    tx: &mut PgConnection,
1229    namespace_id: Uuid,
1230    parent_exam_id: Uuid,
1231) -> ModelResult<HashSet<Uuid>> {
1232    let copied_ids = sqlx::query!(
1233        "
1234INSERT INTO exercises (
1235    id,
1236    exam_id,
1237    name,
1238    deadline,
1239    page_id,
1240    score_maximum,
1241    order_number,
1242    chapter_id,
1243    copied_from,
1244    max_tries_per_slide,
1245    limit_number_of_tries,
1246    needs_peer_review,
1247    use_course_default_peer_or_self_review_config,
1248    needs_self_review,
1249    teacher_reviews_answer_after_locking
1250  )
1251SELECT uuid_generate_v5($1, e.id::text),
1252  $1,
1253  e.name,
1254  e.deadline,
1255  copied_page.id,
1256  e.score_maximum,
1257  e.order_number,
1258  NULL,
1259  e.id,
1260  e.max_tries_per_slide,
1261  e.limit_number_of_tries,
1262  e.needs_peer_review,
1263  e.use_course_default_peer_or_self_review_config,
1264  e.needs_self_review,
1265  e.teacher_reviews_answer_after_locking
1266FROM exercises e
1267  JOIN pages copied_page ON copied_page.id = uuid_generate_v5($1, e.page_id::text)
1268WHERE e.exam_id = $2
1269  AND e.deleted_at IS NULL
1270RETURNING id;
1271            ",
1272        namespace_id,
1273        parent_exam_id
1274    )
1275    .fetch_all(tx)
1276    .await?
1277    .into_iter()
1278    .map(|record| record.id)
1279    .collect();
1280
1281    Ok(copied_ids)
1282}
1283
1284async fn copy_exercise_slides(
1285    tx: &mut PgConnection,
1286    namespace_id: Uuid,
1287    parent_id: Uuid,
1288) -> ModelResult<()> {
1289    sqlx::query!(
1290        "
1291INSERT INTO exercise_slides (id, exercise_id, order_number)
1292SELECT uuid_generate_v5($1, s.id::text),
1293  copied_exercise.id,
1294  s.order_number
1295FROM exercise_slides s
1296  JOIN exercises e ON e.id = s.exercise_id
1297  JOIN exercises copied_exercise ON copied_exercise.id = uuid_generate_v5($1, s.exercise_id::text)
1298WHERE (e.course_id = $2 OR e.exam_id = $2)
1299  AND s.deleted_at IS NULL;
1300            ",
1301        namespace_id,
1302        parent_id
1303    )
1304    .execute(&mut *tx)
1305    .await?;
1306
1307    Ok(())
1308}
1309
1310async fn copy_exercise_tasks(
1311    tx: &mut PgConnection,
1312    namespace_id: Uuid,
1313    parent_id: Uuid,
1314) -> ModelResult<()> {
1315    sqlx::query!(
1316        "
1317INSERT INTO exercise_tasks (
1318    id,
1319    exercise_slide_id,
1320    exercise_type,
1321    assignment,
1322    private_spec,
1323    public_spec,
1324    model_solution_spec,
1325    order_number,
1326    copied_from
1327  )
1328SELECT uuid_generate_v5($1, t.id::text),
1329  copied_slide.id,
1330  t.exercise_type,
1331  t.assignment,
1332  t.private_spec,
1333  t.public_spec,
1334  t.model_solution_spec,
1335  t.order_number,
1336  t.id
1337FROM exercise_tasks t
1338  JOIN exercise_slides s ON s.id = t.exercise_slide_id
1339  JOIN exercises e ON e.id = s.exercise_id
1340  JOIN exercise_slides copied_slide ON copied_slide.id = uuid_generate_v5($1, t.exercise_slide_id::text)
1341WHERE (e.course_id = $2 OR e.exam_id = $2)
1342  AND t.deleted_at IS NULL;
1343    ",
1344        namespace_id,
1345        parent_id,
1346    )
1347    .execute(&mut *tx)
1348    .await?;
1349
1350    // The copied specs name the same stored files as the originals, so the copy has to declare
1351    // them too. Otherwise only the original task declares them, and editing or deleting the
1352    // original leaves the copy serving files the reaper is free to delete.
1353    sqlx::query!(
1354        "
1355INSERT INTO exercise_task_spec_files (exercise_task_id, file_upload_id, spec_kind)
1356SELECT copied_task.id,
1357  f.file_upload_id,
1358  f.spec_kind
1359FROM exercise_task_spec_files f
1360  JOIN exercise_tasks t ON t.id = f.exercise_task_id
1361  JOIN exercise_slides s ON s.id = t.exercise_slide_id
1362  JOIN exercises e ON e.id = s.exercise_id
1363  JOIN exercise_tasks copied_task ON copied_task.id = uuid_generate_v5($1, f.exercise_task_id::text)
1364WHERE (e.course_id = $2 OR e.exam_id = $2)
1365  AND f.deleted_at IS NULL;
1366    ",
1367        namespace_id,
1368        parent_id,
1369    )
1370    .execute(&mut *tx)
1371    .await?;
1372    Ok(())
1373}
1374
1375pub async fn copy_user_permissions(
1376    conn: &mut PgConnection,
1377    new_course_id: Uuid,
1378    old_course_id: Uuid,
1379    user_id: Uuid,
1380) -> ModelResult<()> {
1381    sqlx::query!(
1382        "
1383INSERT INTO roles (
1384    id,
1385    user_id,
1386    organization_id,
1387    course_id,
1388    role
1389  )
1390SELECT uuid_generate_v5($2, id::text),
1391  user_id,
1392  organization_id,
1393  $2,
1394  role
1395FROM roles
1396WHERE (course_id = $1)
1397AND NOT (user_id = $3)
1398AND deleted_at IS NULL;
1399    ",
1400        old_course_id,
1401        new_course_id,
1402        user_id
1403    )
1404    .execute(conn)
1405    .await?;
1406    Ok(())
1407}
1408
1409async fn copy_peer_or_self_review_configs(
1410    tx: &mut PgConnection,
1411    namespace_id: Uuid,
1412    parent_id: Uuid,
1413) -> ModelResult<()> {
1414    // Matched on the exercise's course, not the config's: older configs can carry the course_id of
1415    // the course their exercise block was pasted from.
1416    sqlx::query!(
1417        "
1418INSERT INTO peer_or_self_review_configs (
1419    id,
1420    course_id,
1421    exercise_id,
1422    peer_reviews_to_give,
1423    peer_reviews_to_receive,
1424    processing_strategy,
1425    accepting_threshold,
1426    manual_review_cutoff_in_days,
1427    points_are_all_or_nothing,
1428    review_instructions,
1429    reset_answer_if_zero_points_from_review
1430  )
1431SELECT uuid_generate_v5($1, posrc.id::text),
1432  $1,
1433  copied_exercise.id,
1434  posrc.peer_reviews_to_give,
1435  posrc.peer_reviews_to_receive,
1436  posrc.processing_strategy,
1437  posrc.accepting_threshold,
1438  posrc.manual_review_cutoff_in_days,
1439  posrc.points_are_all_or_nothing,
1440  posrc.review_instructions,
1441  posrc.reset_answer_if_zero_points_from_review
1442FROM peer_or_self_review_configs posrc
1443  LEFT JOIN exercises e ON (e.id = posrc.exercise_id)
1444  LEFT JOIN exercises copied_exercise ON (
1445    copied_exercise.id = uuid_generate_v5($1, posrc.exercise_id::text)
1446  )
1447WHERE posrc.deleted_at IS NULL
1448  AND (
1449    (
1450      posrc.exercise_id IS NULL
1451      AND posrc.course_id = $2
1452    )
1453    OR (
1454      e.course_id = $2
1455      AND copied_exercise.id IS NOT NULL
1456    )
1457  );
1458    ",
1459        namespace_id,
1460        parent_id,
1461    )
1462    .execute(&mut *tx)
1463    .await?;
1464    Ok(())
1465}
1466
1467async fn copy_peer_or_self_review_questions(
1468    tx: &mut PgConnection,
1469    namespace_id: Uuid,
1470    parent_id: Uuid,
1471) -> ModelResult<()> {
1472    sqlx::query!(
1473        "
1474INSERT INTO peer_or_self_review_questions (
1475    id,
1476    peer_or_self_review_config_id,
1477    order_number,
1478    question,
1479    question_type,
1480    answer_required,
1481    weight
1482  )
1483SELECT uuid_generate_v5($1, q.id::text),
1484  copied_config.id,
1485  q.order_number,
1486  q.question,
1487  q.question_type,
1488  q.answer_required,
1489  q.weight
1490FROM peer_or_self_review_questions q
1491  JOIN peer_or_self_review_configs posrc ON (posrc.id = q.peer_or_self_review_config_id)
1492  JOIN peer_or_self_review_configs copied_config ON (
1493    copied_config.id = uuid_generate_v5($1, q.peer_or_self_review_config_id::text)
1494  )
1495  LEFT JOIN exercises e ON (e.id = posrc.exercise_id)
1496WHERE q.deleted_at IS NULL
1497  AND (
1498    posrc.course_id = $2
1499    OR e.course_id = $2
1500  );
1501    ",
1502        namespace_id,
1503        parent_id,
1504    )
1505    .execute(&mut *tx)
1506    .await?;
1507    Ok(())
1508}
1509
1510async fn copy_material_references(
1511    tx: &mut PgConnection,
1512    namespace_id: Uuid,
1513    parent_id: Uuid,
1514) -> ModelResult<()> {
1515    // Copy material references
1516    sqlx::query!(
1517        "
1518INSERT INTO material_references (
1519    citation_key,
1520    course_id,
1521    id,
1522    reference
1523)
1524SELECT citation_key,
1525  $1,
1526  uuid_generate_v5($1, id::text),
1527  reference
1528FROM material_references
1529WHERE course_id = $2
1530AND deleted_at IS NULL;
1531    ",
1532        namespace_id,
1533        parent_id,
1534    )
1535    .execute(&mut *tx)
1536    .await?;
1537    Ok(())
1538}
1539
1540async fn copy_glossary_entries(
1541    tx: &mut PgConnection,
1542    new_course_id: Uuid,
1543    old_course_id: Uuid,
1544) -> ModelResult<()> {
1545    sqlx::query!(
1546        "
1547INSERT INTO glossary (
1548    id,
1549    course_id,
1550    term,
1551    definition
1552  )
1553SELECT uuid_generate_v5($1, id::text),
1554  $1,
1555  term,
1556  definition
1557FROM glossary
1558WHERE course_id = $2
1559  AND deleted_at IS NULL;
1560        ",
1561        new_course_id,
1562        old_course_id,
1563    )
1564    .execute(&mut *tx)
1565    .await?;
1566    Ok(())
1567}
1568
1569async fn copy_certificate_configurations_and_requirements(
1570    tx: &mut PgConnection,
1571    new_course_id: Uuid,
1572    old_course_id: Uuid,
1573) -> ModelResult<()> {
1574    sqlx::query!(
1575        "
1576INSERT INTO certificate_configurations (
1577    id,
1578    background_svg_file_upload_id,
1579    background_svg_path,
1580    certificate_date_font_size,
1581    certificate_date_text_anchor,
1582    certificate_date_text_color,
1583    certificate_date_x_pos,
1584    certificate_date_y_pos,
1585    certificate_grade_font_size,
1586    certificate_grade_text_anchor,
1587    certificate_grade_text_color,
1588    certificate_grade_x_pos,
1589    certificate_grade_y_pos,
1590    certificate_locale,
1591    certificate_owner_name_font_size,
1592    certificate_owner_name_text_anchor,
1593    certificate_owner_name_text_color,
1594    certificate_owner_name_x_pos,
1595    certificate_owner_name_y_pos,
1596    certificate_validate_url_font_size,
1597    certificate_validate_url_text_anchor,
1598    certificate_validate_url_text_color,
1599    certificate_validate_url_x_pos,
1600    certificate_validate_url_y_pos,
1601    overlay_svg_file_upload_id,
1602    overlay_svg_path,
1603    paper_size,
1604    render_certificate_grade
1605  )
1606SELECT uuid_generate_v5($1, id::text),
1607  background_svg_file_upload_id,
1608  background_svg_path,
1609  certificate_date_font_size,
1610  certificate_date_text_anchor,
1611  certificate_date_text_color,
1612  certificate_date_x_pos,
1613  certificate_date_y_pos,
1614  certificate_grade_font_size,
1615  certificate_grade_text_anchor,
1616  certificate_grade_text_color,
1617  certificate_grade_x_pos,
1618  certificate_grade_y_pos,
1619  certificate_locale,
1620  certificate_owner_name_font_size,
1621  certificate_owner_name_text_anchor,
1622  certificate_owner_name_text_color,
1623  certificate_owner_name_x_pos,
1624  certificate_owner_name_y_pos,
1625  certificate_validate_url_font_size,
1626  certificate_validate_url_text_anchor,
1627  certificate_validate_url_text_color,
1628  certificate_validate_url_x_pos,
1629  certificate_validate_url_y_pos,
1630  overlay_svg_file_upload_id,
1631  overlay_svg_path,
1632  paper_size,
1633  render_certificate_grade
1634FROM certificate_configurations
1635WHERE id IN (
1636    SELECT certificate_configuration_id
1637    FROM certificate_configuration_to_requirements cctr
1638      JOIN course_modules cm ON cctr.course_module_id = cm.id
1639    WHERE cm.course_id = $2
1640      AND cctr.deleted_at IS NULL
1641      AND cm.deleted_at IS NULL
1642  )
1643  AND deleted_at IS NULL;
1644        ",
1645        new_course_id,
1646        old_course_id
1647    )
1648    .execute(&mut *tx)
1649    .await?;
1650
1651    sqlx::query!(
1652        "
1653INSERT INTO certificate_configuration_to_requirements (
1654    id,
1655    certificate_configuration_id,
1656    course_module_id
1657  )
1658SELECT uuid_generate_v5($1, cctr.id::text),
1659  uuid_generate_v5($1, cctr.certificate_configuration_id::text),
1660  uuid_generate_v5($1, cctr.course_module_id::text)
1661FROM certificate_configuration_to_requirements cctr
1662  JOIN course_modules cm ON cctr.course_module_id = cm.id
1663  JOIN certificate_configurations cc ON cctr.certificate_configuration_id = cc.id
1664WHERE cm.course_id = $2
1665  AND cctr.deleted_at IS NULL
1666  AND cm.deleted_at IS NULL
1667  AND cc.deleted_at IS NULL;
1668        ",
1669        new_course_id,
1670        old_course_id
1671    )
1672    .execute(&mut *tx)
1673    .await?;
1674
1675    Ok(())
1676}
1677
1678/// Returns the ids of the copied configurations.
1679async fn copy_chatbot_configurations(
1680    tx: &mut PgConnection,
1681    new_course_id: Uuid,
1682    old_course_id: Uuid,
1683) -> ModelResult<HashSet<Uuid>> {
1684    let copied_ids = sqlx::query!(
1685        "
1686INSERT INTO chatbot_configurations (
1687    id,
1688    course_id,
1689    chatbot_name,
1690    initial_message,
1691    prompt,
1692    use_azure_search,
1693    maintain_azure_search_index,
1694    use_semantic_reranking,
1695    hide_citations,
1696    temperature,
1697    top_p,
1698    presence_penalty,
1699    frequency_penalty,
1700    max_output_tokens,
1701    daily_tokens_per_user,
1702    weekly_tokens_per_user,
1703    default_chatbot,
1704    enabled_to_students,
1705    model_id,
1706    enabled_tool_categories,
1707    verbosity,
1708    reasoning_effort,
1709    suggest_next_messages,
1710    initial_suggested_messages,
1711    publicly_accessible,
1712    embeddable
1713  )
1714SELECT
1715  uuid_generate_v5($1, id::text),
1716  $1,
1717  chatbot_name,
1718  initial_message,
1719  prompt,
1720  use_azure_search,
1721  maintain_azure_search_index,
1722  use_semantic_reranking,
1723  hide_citations,
1724  temperature,
1725  top_p,
1726  presence_penalty,
1727  frequency_penalty,
1728  max_output_tokens,
1729  daily_tokens_per_user,
1730  weekly_tokens_per_user,
1731  default_chatbot,
1732  enabled_to_students,
1733  model_id,
1734  enabled_tool_categories,
1735  verbosity,
1736  reasoning_effort,
1737  suggest_next_messages,
1738  initial_suggested_messages,
1739  publicly_accessible,
1740  embeddable
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}