Skip to main content

headless_lms_models/library/
copying.rs

1use std::collections::HashMap;
2
3use serde_json::Value;
4
5use crate::course_instances;
6use crate::course_instances::NewCourseInstance;
7use crate::course_language_groups;
8use crate::courses::Course;
9use crate::courses::CourseAiPolicy;
10use crate::courses::NewCourse;
11use crate::courses::get_course;
12use crate::exams;
13use crate::exams::Exam;
14use crate::exams::NewExam;
15use crate::pages;
16use crate::prelude::*;
17
18use crate::ModelResult;
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
59    let mut tx = conn.begin().await?;
60
61    let copied_course = sqlx::query_as!(
62        Course,
63        r#"
64INSERT INTO courses (
65    name,
66    organization_id,
67    slug,
68    content_search_language,
69    language_code,
70    copied_from,
71    course_language_group_id,
72    is_draft,
73    base_module_completion_requires_n_submodule_completions,
74    can_add_chatbot,
75    is_unlisted,
76    is_joinable_by_code_only,
77    join_code,
78    ask_marketing_consent,
79    description,
80    flagged_answers_threshold,
81    flagged_answers_skip_manual_review_and_allow_retry,
82    cheater_detection_enabled,
83    chapter_locking_enabled,
84    ai_policy,
85    course_material_ai_instructions
86  )
87VALUES (
88    $1,
89    $2,
90    $3,
91    $4,
92    $5,
93    $6,
94    $7,
95    $8,
96    $9,
97    $10,
98    $11,
99    $12,
100    $13,
101    $14,
102    $15,
103    $16,
104    $17,
105    $18,
106    $19,
107    $20,
108    $21
109  )
110RETURNING id,
111  name,
112  created_at,
113  updated_at,
114  organization_id,
115  deleted_at,
116  slug,
117  content_search_language::text,
118  language_code,
119  copied_from,
120  course_language_group_id,
121  description,
122  is_draft,
123  is_test_mode,
124  base_module_completion_requires_n_submodule_completions,
125  can_add_chatbot,
126  is_unlisted,
127  is_joinable_by_code_only,
128  join_code,
129  ask_marketing_consent,
130  flagged_answers_threshold,
131  flagged_answers_skip_manual_review_and_allow_retry,
132  closed_at,
133  closed_additional_message,
134  closed_course_successor_id,
135  chapter_locking_enabled,
136  cheater_detection_enabled,
137  ai_policy,
138  course_material_ai_instructions
139        "#,
140        new_course.name,
141        new_course.organization_id,
142        new_course.slug,
143        parent_course.content_search_language as _,
144        new_course.language_code,
145        parent_course.id,
146        target_clg_id,
147        new_course.is_draft,
148        parent_course.base_module_completion_requires_n_submodule_completions,
149        parent_course.can_add_chatbot,
150        new_course.is_unlisted,
151        new_course.is_joinable_by_code_only,
152        new_course.join_code,
153        new_course.ask_marketing_consent,
154        parent_course.description,
155        parent_course.flagged_answers_threshold,
156        parent_course.flagged_answers_skip_manual_review_and_allow_retry,
157        parent_course.cheater_detection_enabled,
158        parent_course.chapter_locking_enabled,
159        parent_course.ai_policy as CourseAiPolicy,
160        parent_course.course_material_ai_instructions
161    )
162    .fetch_one(&mut *tx)
163    .await?;
164
165    copy_course_modules(&mut tx, copied_course.id, src_course_id).await?;
166    copy_course_chapters(&mut tx, copied_course.id, src_course_id).await?;
167
168    if new_course.copy_user_permissions {
169        copy_user_permissions(&mut tx, copied_course.id, src_course_id, user_id).await?;
170    }
171
172    let contents_iter =
173        copy_course_pages_and_return_contents(&mut tx, copied_course.id, src_course_id).await?;
174
175    set_chapter_front_pages(&mut tx, copied_course.id).await?;
176
177    let old_to_new_exercise_ids = map_old_exr_ids_to_new_exr_ids_for_courses(
178        &mut tx,
179        copied_course.id,
180        src_course_id,
181        target_clg_id,
182        same_clg,
183    )
184    .await?;
185
186    // update page contents exercise IDs
187    for (page_id, content) in contents_iter {
188        if let Value::Array(mut blocks) = content {
189            for block in blocks.iter_mut() {
190                if block["name"] != Value::String("moocfi/exercise".to_string()) {
191                    continue;
192                }
193                if let Value::String(old_id) = &block["attributes"]["id"] {
194                    let new_id = old_to_new_exercise_ids
195                        .get(old_id)
196                        .ok_or_else(|| {
197                            ModelError::new(
198                                ModelErrorType::Generic,
199                                "Invalid exercise id in content.".to_string(),
200                                None,
201                            )
202                        })?
203                        .to_string();
204                    block["attributes"]["id"] = Value::String(new_id);
205                }
206            }
207            sqlx::query!(
208                r#"
209UPDATE pages
210SET content = $1
211WHERE id = $2;
212"#,
213                Value::Array(blocks),
214                page_id
215            )
216            .execute(&mut *tx)
217            .await?;
218        }
219    }
220
221    let pages_contents = pages::get_all_by_course_id_and_visibility(
222        tx.as_mut(),
223        copied_course.id,
224        pages::PageVisibility::Any,
225    )
226    .await?
227    .into_iter()
228    .map(|page| (page.id, page.content))
229    .collect::<HashMap<_, _>>();
230
231    for (page_id, content) in pages_contents {
232        if let Value::Array(mut blocks) = content {
233            for block in blocks.iter_mut() {
234                if let Some(content) = block["attributes"]["content"].as_str()
235                    && content.contains("<a href=")
236                {
237                    block["attributes"]["content"] =
238                        Value::String(content.replace(&parent_course.slug, &new_course.slug));
239                }
240            }
241            sqlx::query!(
242                r#"
243UPDATE pages
244SET content = $1
245WHERE id = $2;
246"#,
247                Value::Array(blocks),
248                page_id
249            )
250            .execute(&mut *tx)
251            .await?;
252        }
253    }
254
255    copy_exercise_slides(&mut tx, copied_course.id, src_course_id).await?;
256    copy_exercise_tasks(&mut tx, copied_course.id, src_course_id).await?;
257
258    // 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.
259    course_instances::insert(
260        &mut tx,
261        PKeyPolicy::Generate,
262        NewCourseInstance {
263            course_id: copied_course.id,
264            name: None,
265            description: None,
266            support_email: None,
267            teacher_in_charge_name: &new_course.teacher_in_charge_name,
268            teacher_in_charge_email: &new_course.teacher_in_charge_email,
269            opening_time: None,
270            closing_time: None,
271        },
272    )
273    .await?;
274
275    copy_peer_or_self_review_configs(&mut tx, copied_course.id, src_course_id).await?;
276    copy_peer_or_self_review_questions(&mut tx, copied_course.id, src_course_id).await?;
277    copy_material_references(&mut tx, copied_course.id, src_course_id).await?;
278    copy_glossary_entries(&mut tx, copied_course.id, src_course_id).await?;
279
280    // Copy course configurations and optional content
281    copy_certificate_configurations_and_requirements(&mut tx, copied_course.id, src_course_id)
282        .await?;
283    copy_chatbot_configurations(&mut tx, copied_course.id, src_course_id).await?;
284    copy_cheater_thresholds(&mut tx, copied_course.id, src_course_id).await?;
285    copy_course_custom_privacy_policy_checkbox_texts(&mut tx, copied_course.id, src_course_id)
286        .await?;
287    copy_exercise_repositories(&mut tx, copied_course.id, src_course_id).await?;
288    copy_partners_blocks(&mut tx, copied_course.id, src_course_id).await?;
289    copy_privacy_links(&mut tx, copied_course.id, src_course_id).await?;
290    copy_research_consent_forms_and_questions(&mut tx, copied_course.id, src_course_id).await?;
291
292    tx.commit().await?;
293
294    Ok(copied_course)
295}
296
297pub async fn copy_exam(
298    conn: &mut PgConnection,
299    parent_exam_id: &Uuid,
300    new_exam: &NewExam,
301) -> ModelResult<Exam> {
302    let mut tx = conn.begin().await?;
303    let copied_exam = copy_exam_content(&mut tx, parent_exam_id, new_exam, None).await?;
304    tx.commit().await?;
305    Ok(copied_exam)
306}
307
308async fn copy_exam_content(
309    tx: &mut PgConnection,
310    parent_exam_id: &Uuid,
311    new_exam: &NewExam,
312    new_exam_id: Option<Uuid>,
313) -> ModelResult<Exam> {
314    let parent_exam = exams::get(tx, *parent_exam_id).await?;
315
316    let parent_exam_fields = sqlx::query!(
317        "
318SELECT *
319FROM exams
320WHERE id = $1
321        ",
322        parent_exam.id
323    )
324    .fetch_one(&mut *tx)
325    .await?;
326
327    let final_exam_id = new_exam_id.unwrap_or_else(Uuid::new_v4);
328
329    // create new exam
330    let copied_exam = sqlx::query!(
331        "
332INSERT INTO exams(
333    id,
334    name,
335    organization_id,
336    instructions,
337    starts_at,
338    ends_at,
339    language,
340    time_minutes,
341    minimum_points_treshold,
342    grade_manually
343  )
344VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
345RETURNING *
346        ",
347        final_exam_id,
348        new_exam.name,
349        parent_exam_fields.organization_id,
350        parent_exam.instructions,
351        new_exam.starts_at,
352        new_exam.ends_at,
353        parent_exam_fields.language,
354        new_exam.time_minutes,
355        parent_exam_fields.minimum_points_treshold,
356        new_exam.grade_manually,
357    )
358    .fetch_one(&mut *tx)
359    .await?;
360
361    let contents_iter =
362        copy_exam_pages_and_return_contents(&mut *tx, copied_exam.id, parent_exam.id).await?;
363
364    // Copy exam exercises
365    let old_to_new_exercise_ids =
366        map_old_exr_ids_to_new_exr_ids_for_exams(&mut *tx, copied_exam.id, parent_exam.id).await?;
367
368    // Replace exercise ids in page contents.
369    for (page_id, content) in contents_iter {
370        if let Value::Array(mut blocks) = content {
371            for block in blocks.iter_mut() {
372                if block["name"] != Value::String("moocfi/exercise".to_string()) {
373                    continue;
374                }
375                if let Value::String(old_id) = &block["attributes"]["id"] {
376                    let new_id = old_to_new_exercise_ids
377                        .get(old_id)
378                        .ok_or_else(|| {
379                            ModelError::new(
380                                ModelErrorType::Generic,
381                                "Invalid exercise id in content.".to_string(),
382                                None,
383                            )
384                        })?
385                        .to_string();
386                    block["attributes"]["id"] = Value::String(new_id);
387                }
388            }
389            sqlx::query!(
390                "
391UPDATE pages
392SET content = $1
393WHERE id = $2;
394                ",
395                Value::Array(blocks),
396                page_id,
397            )
398            .execute(&mut *tx)
399            .await?;
400        }
401    }
402
403    copy_exercise_slides(&mut *tx, copied_exam.id, parent_exam.id).await?;
404    copy_exercise_tasks(&mut *tx, copied_exam.id, parent_exam.id).await?;
405
406    let get_page_id = sqlx::query!("SELECT id FROM pages WHERE exam_id = $1;", copied_exam.id)
407        .fetch_one(&mut *tx)
408        .await?;
409
410    Ok(Exam {
411        courses: vec![], // no related courses on newly copied exam
412        ends_at: copied_exam.ends_at,
413        starts_at: copied_exam.starts_at,
414        id: copied_exam.id,
415        instructions: copied_exam.instructions,
416        name: copied_exam.name,
417        time_minutes: copied_exam.time_minutes,
418        page_id: get_page_id.id,
419        minimum_points_treshold: copied_exam.minimum_points_treshold,
420        language: copied_exam
421            .language
422            .unwrap_or_else(|| parent_exam_fields.language.unwrap_or("en-US".to_string())),
423        grade_manually: copied_exam.grade_manually,
424    })
425}
426
427async fn copy_course_pages_and_return_contents(
428    tx: &mut PgConnection,
429    namespace_id: Uuid,
430    parent_course_id: Uuid,
431) -> ModelResult<HashMap<Uuid, Value>> {
432    // Copy course pages. At this point, exercise ids in content will point to old course's exercises.
433    let contents = sqlx::query!(
434        "
435INSERT INTO pages (
436    id,
437    course_id,
438    content,
439    url_path,
440    title,
441    chapter_id,
442    order_number,
443    copied_from,
444    content_search_language,
445    page_language_group_id,
446    hidden
447  )
448SELECT uuid_generate_v5($1, id::text),
449  $1,
450  content,
451  url_path,
452  title,
453  uuid_generate_v5($1, chapter_id::text),
454  order_number,
455  id,
456  content_search_language,
457  page_language_group_id,
458  hidden
459FROM pages
460WHERE (course_id = $2)
461  AND deleted_at IS NULL
462RETURNING id,
463  content;
464        ",
465        namespace_id,
466        parent_course_id
467    )
468    .fetch_all(tx)
469    .await?
470    .into_iter()
471    .map(|record| (record.id, record.content))
472    .collect();
473
474    Ok(contents)
475}
476
477async fn copy_exam_pages_and_return_contents(
478    tx: &mut PgConnection,
479    namespace_id: Uuid,
480    parent_exam_id: Uuid,
481) -> ModelResult<HashMap<Uuid, Value>> {
482    let contents = sqlx::query!(
483        "
484INSERT INTO pages (
485    id,
486    exam_id,
487    content,
488    url_path,
489    title,
490    chapter_id,
491    order_number,
492    copied_from,
493    content_search_language,
494    hidden
495  )
496SELECT uuid_generate_v5($1, id::text),
497  $1,
498  content,
499  url_path,
500  title,
501  NULL,
502  order_number,
503  id,
504  content_search_language,
505  hidden
506FROM pages
507WHERE (exam_id = $2)
508  AND deleted_at IS NULL
509RETURNING id,
510  content;
511        ",
512        namespace_id,
513        parent_exam_id
514    )
515    .fetch_all(tx)
516    .await?
517    .into_iter()
518    .map(|record| (record.id, record.content))
519    .collect();
520
521    Ok(contents)
522}
523
524async fn set_chapter_front_pages(tx: &mut PgConnection, namespace_id: Uuid) -> ModelResult<()> {
525    // Update front_page_id of chapters now that new pages exist.
526    sqlx::query!(
527        "
528UPDATE chapters
529SET front_page_id = uuid_generate_v5(course_id, front_page_id::text)
530WHERE course_id = $1
531  AND front_page_id IS NOT NULL;
532            ",
533        namespace_id,
534    )
535    .execute(&mut *tx)
536    .await?;
537
538    Ok(())
539}
540
541async fn copy_course_modules(
542    tx: &mut PgConnection,
543    new_course_id: Uuid,
544    old_course_id: Uuid,
545) -> ModelResult<()> {
546    sqlx::query!(
547        "
548INSERT INTO course_modules (
549    id,
550    course_id,
551    name,
552    order_number,
553    copied_from,
554    automatic_completion,
555    automatic_completion_number_of_exercises_attempted_treshold,
556    automatic_completion_number_of_points_treshold,
557    automatic_completion_requires_exam,
558    certification_enabled,
559    completion_registration_link_override,
560    ects_credits,
561    enable_registering_completion_to_uh_open_university,
562    uh_course_code
563  )
564SELECT uuid_generate_v5($1, id::text),
565  $1,
566  name,
567  order_number,
568  id,
569  automatic_completion,
570  automatic_completion_number_of_exercises_attempted_treshold,
571  automatic_completion_number_of_points_treshold,
572  automatic_completion_requires_exam,
573  certification_enabled,
574  completion_registration_link_override,
575  ects_credits,
576  enable_registering_completion_to_uh_open_university,
577  uh_course_code
578FROM course_modules
579WHERE course_id = $2
580  AND deleted_at IS NULL
581        ",
582        new_course_id,
583        old_course_id,
584    )
585    .execute(&mut *tx)
586    .await?;
587    Ok(())
588}
589
590/// After this one `set_chapter_front_pages` needs to be called to get these to point to the correct front pages.
591async fn copy_course_chapters(
592    tx: &mut PgConnection,
593    namespace_id: Uuid,
594    parent_course_id: Uuid,
595) -> ModelResult<()> {
596    sqlx::query!(
597        "
598INSERT INTO chapters (
599    id,
600    name,
601    course_id,
602    chapter_number,
603    front_page_id,
604    opens_at,
605    chapter_image_path,
606    copied_from,
607    course_module_id,
608    color,
609    deadline
610  )
611SELECT uuid_generate_v5($1, id::text),
612  name,
613  $1,
614  chapter_number,
615  front_page_id,
616  opens_at,
617  chapter_image_path,
618  id,
619  uuid_generate_v5($1, course_module_id::text),
620  color,
621  deadline
622FROM chapters
623WHERE (course_id = $2)
624  AND deleted_at IS NULL;
625    ",
626        namespace_id,
627        parent_course_id
628    )
629    .execute(&mut *tx)
630    .await?;
631
632    Ok(())
633}
634
635async fn map_old_exr_ids_to_new_exr_ids_for_courses(
636    tx: &mut PgConnection,
637    new_course_id: Uuid,
638    src_course_id: Uuid,
639    target_clg_id: Uuid,
640    same_clg: bool,
641) -> ModelResult<HashMap<String, String>> {
642    let rows = sqlx::query!(
643        r#"
644WITH src AS (
645  SELECT e.*,
646    CASE
647      WHEN $4 THEN e.exercise_language_group_id
648      ELSE uuid_generate_v5($3, e.id::text)
649    END AS tgt_elg_id
650  FROM exercises e
651  WHERE e.course_id = $2
652    AND e.deleted_at IS NULL
653),
654ins_elg AS (
655  INSERT INTO exercise_language_groups (id, course_language_group_id)
656  SELECT DISTINCT tgt_elg_id,
657    $3
658  FROM src
659  WHERE NOT $4 ON CONFLICT (id) DO NOTHING
660),
661ins_exercises AS (
662  INSERT INTO exercises (
663      id,
664      course_id,
665      name,
666      deadline,
667      page_id,
668      score_maximum,
669      order_number,
670      chapter_id,
671      copied_from,
672      exercise_language_group_id,
673      max_tries_per_slide,
674      limit_number_of_tries,
675      needs_peer_review,
676      use_course_default_peer_or_self_review_config,
677      needs_self_review,
678      teacher_reviews_answer_after_locking
679    )
680  SELECT uuid_generate_v5($1, src.id::text),
681    $1,
682    src.name,
683    src.deadline,
684    uuid_generate_v5($1, src.page_id::text),
685    src.score_maximum,
686    src.order_number,
687    uuid_generate_v5($1, src.chapter_id::text),
688    src.id,
689    src.tgt_elg_id,
690    src.max_tries_per_slide,
691    src.limit_number_of_tries,
692    src.needs_peer_review,
693    src.use_course_default_peer_or_self_review_config,
694    src.needs_self_review,
695    src.teacher_reviews_answer_after_locking
696  FROM src
697  RETURNING id,
698    copied_from
699)
700SELECT id,
701  copied_from
702FROM ins_exercises;
703        "#,
704        new_course_id,
705        src_course_id,
706        target_clg_id,
707        same_clg,
708    )
709    .fetch_all(tx)
710    .await?;
711
712    rows.into_iter()
713        .map(|r| {
714            r.copied_from
715                .ok_or_else(|| {
716                    ModelError::new(
717                        ModelErrorType::Database,
718                        "copied_from should always be set from INSERT statement".to_string(),
719                        None,
720                    )
721                })
722                .map(|copied_from| (copied_from.to_string(), r.id.to_string()))
723        })
724        .collect::<ModelResult<Vec<_>>>()
725        .map(|vec| vec.into_iter().collect())
726}
727
728async fn map_old_exr_ids_to_new_exr_ids_for_exams(
729    tx: &mut PgConnection,
730    namespace_id: Uuid,
731    parent_exam_id: Uuid,
732) -> ModelResult<HashMap<String, String>> {
733    let old_to_new_exercise_ids = sqlx::query!(
734        "
735INSERT INTO exercises (
736    id,
737    exam_id,
738    name,
739    deadline,
740    page_id,
741    score_maximum,
742    order_number,
743    chapter_id,
744    copied_from,
745    max_tries_per_slide,
746    limit_number_of_tries,
747    needs_peer_review,
748    use_course_default_peer_or_self_review_config,
749    needs_self_review,
750    teacher_reviews_answer_after_locking
751  )
752SELECT uuid_generate_v5($1, id::text),
753  $1,
754  name,
755  deadline,
756  uuid_generate_v5($1, page_id::text),
757  score_maximum,
758  order_number,
759  NULL,
760  id,
761  max_tries_per_slide,
762  limit_number_of_tries,
763  needs_peer_review,
764  use_course_default_peer_or_self_review_config,
765  needs_self_review,
766  teacher_reviews_answer_after_locking
767FROM exercises
768WHERE exam_id = $2
769  AND deleted_at IS NULL
770RETURNING id,
771  copied_from;
772            ",
773        namespace_id,
774        parent_exam_id
775    )
776    .fetch_all(tx)
777    .await?
778    .into_iter()
779    .map(|record| {
780        Ok((
781            record
782                .copied_from
783                .ok_or_else(|| {
784                    ModelError::new(
785                        ModelErrorType::Generic,
786                        "Query failed to return valid data.".to_string(),
787                        None,
788                    )
789                })?
790                .to_string(),
791            record.id.to_string(),
792        ))
793    })
794    .collect::<ModelResult<HashMap<String, String>>>()?;
795
796    Ok(old_to_new_exercise_ids)
797}
798
799async fn copy_exercise_slides(
800    tx: &mut PgConnection,
801    namespace_id: Uuid,
802    parent_id: Uuid,
803) -> ModelResult<()> {
804    // Copy exercise slides
805    sqlx::query!(
806        "
807    INSERT INTO exercise_slides (
808        id, exercise_id, order_number
809    )
810    SELECT uuid_generate_v5($1, id::text),
811        uuid_generate_v5($1, exercise_id::text),
812        order_number
813    FROM exercise_slides
814    WHERE exercise_id IN (SELECT id FROM exercises WHERE course_id = $2 OR exam_id = $2 AND deleted_at IS NULL)
815    AND deleted_at IS NULL;
816            ",
817        namespace_id,
818        parent_id
819    )
820    .execute(&mut *tx)
821    .await?;
822
823    Ok(())
824}
825
826async fn copy_exercise_tasks(
827    tx: &mut PgConnection,
828    namespace_id: Uuid,
829    parent_id: Uuid,
830) -> ModelResult<()> {
831    // Copy exercise tasks
832    sqlx::query!(
833        "
834INSERT INTO exercise_tasks (
835    id,
836    exercise_slide_id,
837    exercise_type,
838    assignment,
839    private_spec,
840    public_spec,
841    model_solution_spec,
842    order_number,
843    copied_from
844  )
845SELECT uuid_generate_v5($1, id::text),
846  uuid_generate_v5($1, exercise_slide_id::text),
847  exercise_type,
848  assignment,
849  private_spec,
850  public_spec,
851  model_solution_spec,
852  order_number,
853  id
854FROM exercise_tasks
855WHERE exercise_slide_id IN (
856    SELECT s.id
857    FROM exercise_slides s
858      JOIN exercises e ON (e.id = s.exercise_id)
859    WHERE e.course_id = $2 OR e.exam_id = $2
860    AND e.deleted_at IS NULL
861    AND s.deleted_at IS NULL
862  )
863AND deleted_at IS NULL;
864    ",
865        namespace_id,
866        parent_id,
867    )
868    .execute(&mut *tx)
869    .await?;
870    Ok(())
871}
872
873pub async fn copy_user_permissions(
874    conn: &mut PgConnection,
875    new_course_id: Uuid,
876    old_course_id: Uuid,
877    user_id: Uuid,
878) -> ModelResult<()> {
879    sqlx::query!(
880        "
881INSERT INTO roles (
882    id,
883    user_id,
884    organization_id,
885    course_id,
886    role
887  )
888SELECT uuid_generate_v5($2, id::text),
889  user_id,
890  organization_id,
891  $2,
892  role
893FROM roles
894WHERE (course_id = $1)
895AND NOT (user_id = $3)
896AND deleted_at IS NULL;
897    ",
898        old_course_id,
899        new_course_id,
900        user_id
901    )
902    .execute(conn)
903    .await?;
904    Ok(())
905}
906
907async fn copy_peer_or_self_review_configs(
908    tx: &mut PgConnection,
909    namespace_id: Uuid,
910    parent_id: Uuid,
911) -> ModelResult<()> {
912    sqlx::query!(
913        "
914INSERT INTO peer_or_self_review_configs (
915    id,
916    course_id,
917    exercise_id,
918    peer_reviews_to_give,
919    peer_reviews_to_receive,
920    processing_strategy,
921    accepting_threshold,
922    manual_review_cutoff_in_days,
923    points_are_all_or_nothing,
924    review_instructions
925  )
926SELECT uuid_generate_v5($1, posrc.id::text),
927  $1,
928  uuid_generate_v5($1, posrc.exercise_id::text),
929  posrc.peer_reviews_to_give,
930  posrc.peer_reviews_to_receive,
931  posrc.processing_strategy,
932  posrc.accepting_threshold,
933  posrc.manual_review_cutoff_in_days,
934  posrc.points_are_all_or_nothing,
935  posrc.review_instructions
936FROM peer_or_self_review_configs posrc
937  LEFT JOIN exercises e ON (e.id = posrc.exercise_id)
938WHERE posrc.course_id = $2
939  AND posrc.deleted_at IS NULL
940  AND e.deleted_at IS NULL;
941    ",
942        namespace_id,
943        parent_id,
944    )
945    .execute(&mut *tx)
946    .await?;
947    Ok(())
948}
949
950async fn copy_peer_or_self_review_questions(
951    tx: &mut PgConnection,
952    namespace_id: Uuid,
953    parent_id: Uuid,
954) -> ModelResult<()> {
955    sqlx::query!(
956        "
957INSERT INTO peer_or_self_review_questions (
958    id,
959    peer_or_self_review_config_id,
960    order_number,
961    question,
962    question_type,
963    answer_required,
964    weight
965  )
966SELECT uuid_generate_v5($1, q.id::text),
967  uuid_generate_v5($1, q.peer_or_self_review_config_id::text),
968  q.order_number,
969  q.question,
970  q.question_type,
971  q.answer_required,
972  q.weight
973FROM peer_or_self_review_questions q
974  JOIN peer_or_self_review_configs posrc ON (posrc.id = q.peer_or_self_review_config_id)
975  JOIN exercises e ON (e.id = posrc.exercise_id)
976WHERE peer_or_self_review_config_id IN (
977    SELECT id
978    FROM peer_or_self_review_configs
979    WHERE course_id = $2
980      AND deleted_at IS NULL
981  )
982  AND q.deleted_at IS NULL
983  AND e.deleted_at IS NULL
984  AND posrc.deleted_at IS NULL;
985    ",
986        namespace_id,
987        parent_id,
988    )
989    .execute(&mut *tx)
990    .await?;
991    Ok(())
992}
993
994async fn copy_material_references(
995    tx: &mut PgConnection,
996    namespace_id: Uuid,
997    parent_id: Uuid,
998) -> ModelResult<()> {
999    // Copy material references
1000    sqlx::query!(
1001        "
1002INSERT INTO material_references (
1003    citation_key,
1004    course_id,
1005    id,
1006    reference
1007)
1008SELECT citation_key,
1009  $1,
1010  uuid_generate_v5($1, id::text),
1011  reference
1012FROM material_references
1013WHERE course_id = $2
1014AND deleted_at IS NULL;
1015    ",
1016        namespace_id,
1017        parent_id,
1018    )
1019    .execute(&mut *tx)
1020    .await?;
1021    Ok(())
1022}
1023
1024async fn copy_glossary_entries(
1025    tx: &mut PgConnection,
1026    new_course_id: Uuid,
1027    old_course_id: Uuid,
1028) -> ModelResult<()> {
1029    sqlx::query!(
1030        "
1031INSERT INTO glossary (
1032    id,
1033    course_id,
1034    term,
1035    definition
1036  )
1037SELECT uuid_generate_v5($1, id::text),
1038  $1,
1039  term,
1040  definition
1041FROM glossary
1042WHERE course_id = $2
1043  AND deleted_at IS NULL;
1044        ",
1045        new_course_id,
1046        old_course_id,
1047    )
1048    .execute(&mut *tx)
1049    .await?;
1050    Ok(())
1051}
1052
1053async fn copy_certificate_configurations_and_requirements(
1054    tx: &mut PgConnection,
1055    new_course_id: Uuid,
1056    old_course_id: Uuid,
1057) -> ModelResult<()> {
1058    sqlx::query!(
1059        "
1060INSERT INTO certificate_configurations (
1061    id,
1062    background_svg_file_upload_id,
1063    background_svg_path,
1064    certificate_date_font_size,
1065    certificate_date_text_anchor,
1066    certificate_date_text_color,
1067    certificate_date_x_pos,
1068    certificate_date_y_pos,
1069    certificate_grade_font_size,
1070    certificate_grade_text_anchor,
1071    certificate_grade_text_color,
1072    certificate_grade_x_pos,
1073    certificate_grade_y_pos,
1074    certificate_locale,
1075    certificate_owner_name_font_size,
1076    certificate_owner_name_text_anchor,
1077    certificate_owner_name_text_color,
1078    certificate_owner_name_x_pos,
1079    certificate_owner_name_y_pos,
1080    certificate_validate_url_font_size,
1081    certificate_validate_url_text_anchor,
1082    certificate_validate_url_text_color,
1083    certificate_validate_url_x_pos,
1084    certificate_validate_url_y_pos,
1085    overlay_svg_file_upload_id,
1086    overlay_svg_path,
1087    paper_size,
1088    render_certificate_grade
1089  )
1090SELECT uuid_generate_v5($1, id::text),
1091  background_svg_file_upload_id,
1092  background_svg_path,
1093  certificate_date_font_size,
1094  certificate_date_text_anchor,
1095  certificate_date_text_color,
1096  certificate_date_x_pos,
1097  certificate_date_y_pos,
1098  certificate_grade_font_size,
1099  certificate_grade_text_anchor,
1100  certificate_grade_text_color,
1101  certificate_grade_x_pos,
1102  certificate_grade_y_pos,
1103  certificate_locale,
1104  certificate_owner_name_font_size,
1105  certificate_owner_name_text_anchor,
1106  certificate_owner_name_text_color,
1107  certificate_owner_name_x_pos,
1108  certificate_owner_name_y_pos,
1109  certificate_validate_url_font_size,
1110  certificate_validate_url_text_anchor,
1111  certificate_validate_url_text_color,
1112  certificate_validate_url_x_pos,
1113  certificate_validate_url_y_pos,
1114  overlay_svg_file_upload_id,
1115  overlay_svg_path,
1116  paper_size,
1117  render_certificate_grade
1118FROM certificate_configurations
1119WHERE id IN (
1120    SELECT certificate_configuration_id
1121    FROM certificate_configuration_to_requirements cctr
1122      JOIN course_modules cm ON cctr.course_module_id = cm.id
1123    WHERE cm.course_id = $2
1124      AND cctr.deleted_at IS NULL
1125      AND cm.deleted_at IS NULL
1126  )
1127  AND deleted_at IS NULL;
1128        ",
1129        new_course_id,
1130        old_course_id
1131    )
1132    .execute(&mut *tx)
1133    .await?;
1134
1135    sqlx::query!(
1136        "
1137INSERT INTO certificate_configuration_to_requirements (
1138    id,
1139    certificate_configuration_id,
1140    course_module_id
1141  )
1142SELECT uuid_generate_v5($1, cctr.id::text),
1143  uuid_generate_v5($1, cctr.certificate_configuration_id::text),
1144  uuid_generate_v5($1, cctr.course_module_id::text)
1145FROM certificate_configuration_to_requirements cctr
1146  JOIN course_modules cm ON cctr.course_module_id = cm.id
1147WHERE cm.course_id = $2
1148  AND cctr.deleted_at IS NULL
1149  AND cm.deleted_at IS NULL;
1150        ",
1151        new_course_id,
1152        old_course_id
1153    )
1154    .execute(&mut *tx)
1155    .await?;
1156
1157    Ok(())
1158}
1159
1160async fn copy_chatbot_configurations(
1161    tx: &mut PgConnection,
1162    new_course_id: Uuid,
1163    old_course_id: Uuid,
1164) -> ModelResult<()> {
1165    sqlx::query!(
1166        "
1167INSERT INTO chatbot_configurations (
1168    id,
1169    course_id,
1170    chatbot_name,
1171    initial_message,
1172    prompt,
1173    use_azure_search,
1174    maintain_azure_search_index,
1175    use_semantic_reranking,
1176    hide_citations,
1177    temperature,
1178    top_p,
1179    presence_penalty,
1180    frequency_penalty,
1181    max_output_tokens,
1182    daily_tokens_per_user,
1183    weekly_tokens_per_user,
1184    default_chatbot,
1185    enabled_to_students,
1186    model_id,
1187    use_tools
1188  )
1189SELECT
1190  uuid_generate_v5($1, id::text),
1191  $1,
1192  chatbot_name,
1193  initial_message,
1194  prompt,
1195  use_azure_search,
1196  maintain_azure_search_index,
1197  use_semantic_reranking,
1198  hide_citations,
1199  temperature,
1200  top_p,
1201  presence_penalty,
1202  frequency_penalty,
1203  max_output_tokens,
1204  daily_tokens_per_user,
1205  weekly_tokens_per_user,
1206  default_chatbot,
1207  enabled_to_students,
1208  model_id,
1209  use_tools
1210FROM chatbot_configurations
1211WHERE course_id = $2
1212  AND deleted_at IS NULL;
1213        ",
1214        new_course_id,
1215        old_course_id
1216    )
1217    .execute(&mut *tx)
1218    .await?;
1219    Ok(())
1220}
1221
1222async fn copy_cheater_thresholds(
1223    tx: &mut PgConnection,
1224    new_course_id: Uuid,
1225    old_course_id: Uuid,
1226) -> ModelResult<()> {
1227    let old_default_module =
1228        crate::course_modules::get_default_by_course_id(tx, old_course_id).await?;
1229    let new_default_module =
1230        crate::course_modules::get_default_by_course_id(tx, new_course_id).await?;
1231
1232    sqlx::query!(
1233        "
1234INSERT INTO cheater_thresholds (id, course_module_id, duration_seconds)
1235SELECT
1236  uuid_generate_v5($1, id::text),
1237  $2,
1238  duration_seconds
1239FROM cheater_thresholds
1240WHERE course_module_id = $3
1241  AND deleted_at IS NULL;
1242        ",
1243        new_course_id,
1244        new_default_module.id,
1245        old_default_module.id
1246    )
1247    .execute(&mut *tx)
1248    .await?;
1249    Ok(())
1250}
1251
1252async fn copy_course_custom_privacy_policy_checkbox_texts(
1253    tx: &mut PgConnection,
1254    new_course_id: Uuid,
1255    old_course_id: Uuid,
1256) -> ModelResult<()> {
1257    sqlx::query!(
1258        "
1259INSERT INTO course_custom_privacy_policy_checkbox_texts (id, course_id, text_slug, text_html)
1260SELECT uuid_generate_v5($1, id::text),
1261  $1,
1262  text_slug,
1263  text_html
1264FROM course_custom_privacy_policy_checkbox_texts
1265WHERE course_id = $2
1266  AND deleted_at IS NULL;
1267        ",
1268        new_course_id,
1269        old_course_id
1270    )
1271    .execute(&mut *tx)
1272    .await?;
1273    Ok(())
1274}
1275
1276async fn copy_exercise_repositories(
1277    tx: &mut PgConnection,
1278    new_course_id: Uuid,
1279    old_course_id: Uuid,
1280) -> ModelResult<()> {
1281    sqlx::query!(
1282        "
1283INSERT INTO exercise_repositories (
1284    id,
1285    course_id,
1286    url,
1287    deploy_key,
1288    public_key,
1289    STATUS,
1290    error_message
1291  )
1292SELECT uuid_generate_v5($1, id::text),
1293  $1,
1294  url,
1295  deploy_key,
1296  public_key,
1297  STATUS,
1298  error_message
1299FROM exercise_repositories
1300WHERE course_id = $2
1301  AND deleted_at IS NULL;
1302        ",
1303        new_course_id,
1304        old_course_id
1305    )
1306    .execute(&mut *tx)
1307    .await?;
1308    Ok(())
1309}
1310
1311async fn copy_partners_blocks(
1312    tx: &mut PgConnection,
1313    new_course_id: Uuid,
1314    old_course_id: Uuid,
1315) -> ModelResult<()> {
1316    sqlx::query!(
1317        "
1318INSERT INTO partners_blocks (id, course_id, content)
1319SELECT uuid_generate_v5($1, id::text),
1320  $1,
1321  content
1322FROM partners_blocks
1323WHERE course_id = $2
1324  AND deleted_at IS NULL;
1325        ",
1326        new_course_id,
1327        old_course_id
1328    )
1329    .execute(&mut *tx)
1330    .await?;
1331    Ok(())
1332}
1333
1334async fn copy_privacy_links(
1335    tx: &mut PgConnection,
1336    new_course_id: Uuid,
1337    old_course_id: Uuid,
1338) -> ModelResult<()> {
1339    sqlx::query!(
1340        "
1341INSERT INTO privacy_links (id, course_id, url, title)
1342SELECT uuid_generate_v5($1, id::text),
1343  $1,
1344  url,
1345  title
1346FROM privacy_links
1347WHERE course_id = $2
1348  AND deleted_at IS NULL;
1349        ",
1350        new_course_id,
1351        old_course_id
1352    )
1353    .execute(&mut *tx)
1354    .await?;
1355    Ok(())
1356}
1357
1358async fn copy_research_consent_forms_and_questions(
1359    tx: &mut PgConnection,
1360    new_course_id: Uuid,
1361    old_course_id: Uuid,
1362) -> ModelResult<()> {
1363    sqlx::query!(
1364        "
1365INSERT INTO course_specific_research_consent_forms (id, course_id, content)
1366SELECT uuid_generate_v5($1, id::text),
1367  $1,
1368  content
1369FROM course_specific_research_consent_forms
1370WHERE course_id = $2
1371  AND deleted_at IS NULL;
1372        ",
1373        new_course_id,
1374        old_course_id
1375    )
1376    .execute(&mut *tx)
1377    .await?;
1378
1379    sqlx::query!(
1380        "
1381INSERT INTO course_specific_consent_form_questions (
1382    id,
1383    course_id,
1384    research_consent_form_id,
1385    question
1386  )
1387SELECT uuid_generate_v5($1, id::text),
1388  $1,
1389  uuid_generate_v5($1, research_consent_form_id::text),
1390  question
1391FROM course_specific_consent_form_questions
1392WHERE course_id = $2
1393  AND deleted_at IS NULL;
1394        ",
1395        new_course_id,
1396        old_course_id
1397    )
1398    .execute(&mut *tx)
1399    .await?;
1400
1401    Ok(())
1402}
1403
1404#[cfg(test)]
1405mod tests {
1406    use super::*;
1407    use crate::{exercise_tasks::ExerciseTask, pages::Page, test_helper::*};
1408    use pretty_assertions::assert_eq;
1409
1410    #[tokio::test]
1411    async fn elg_preserved_when_same_course_language_group() {
1412        insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
1413                     :chapter, :page, :exercise);
1414        let original_ex = crate::exercises::get_by_id(tx.as_mut(), exercise)
1415            .await
1416            .unwrap();
1417
1418        /* copy into THE SAME CLG via same_language_group = true */
1419        let new_meta = create_new_course(org, "fi-FI".into());
1420        let copied_course = copy_course(tx.as_mut(), course, &new_meta, true, user)
1421            .await
1422            .unwrap();
1423
1424        let copied_ex = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
1425            .await
1426            .unwrap()
1427            .pop()
1428            .unwrap();
1429
1430        assert_eq!(
1431            original_ex.exercise_language_group_id, copied_ex.exercise_language_group_id,
1432            "ELG must stay identical when CLG is unchanged"
1433        );
1434    }
1435
1436    /// 2.  When we copy to a *different* CLG twice **with the same target id**,
1437    ///     every exercise must get the SAME deterministic ELG each time.
1438    #[tokio::test]
1439    async fn elg_deterministic_when_reusing_target_clg() {
1440        insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
1441                     :chapter, :page, exercise: _e);
1442
1443        // Pre-create a brand-new CLG that both copies will use
1444        let reusable_clg =
1445            course_language_groups::insert(tx.as_mut(), PKeyPolicy::Generate, "reusable-clg")
1446                .await
1447                .unwrap();
1448
1449        let meta1 = create_new_course(org, "en-US".into());
1450        let copy1 =
1451            copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta1, user)
1452                .await
1453                .unwrap();
1454
1455        let meta2 = {
1456            let mut nc = create_new_course(org, "pt-BR".into());
1457            nc.slug = "copied-course-2".into(); // ensure uniqueness
1458            nc
1459        };
1460        let copy2 =
1461            copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta2, user)
1462                .await
1463                .unwrap();
1464
1465        let ex1 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy1.id)
1466            .await
1467            .unwrap()
1468            .pop()
1469            .unwrap();
1470        let ex2 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy2.id)
1471            .await
1472            .unwrap()
1473            .pop()
1474            .unwrap();
1475
1476        assert_ne!(ex1.course_id, ex2.course_id); // different copies
1477        assert_eq!(
1478            ex1.exercise_language_group_id, ex2.exercise_language_group_id,
1479            "ELG must be deterministic for the same (target CLG, src exercise)"
1480        );
1481    }
1482
1483    #[tokio::test]
1484    async fn copies_course_as_different_course_language_group() {
1485        insert_data!(:tx, :user, :org, :course);
1486        let course = crate::courses::get_course(tx.as_mut(), course)
1487            .await
1488            .unwrap();
1489        let new_course = create_new_course(org, "en-US".into());
1490        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, false, user)
1491            .await
1492            .unwrap();
1493        assert_ne!(
1494            course.course_language_group_id,
1495            copied_course.course_language_group_id
1496        );
1497    }
1498
1499    #[tokio::test]
1500    async fn copies_course_as_same_course_language_group() {
1501        insert_data!(:tx, :user, :org, :course);
1502        let course = crate::courses::get_course(tx.as_mut(), course)
1503            .await
1504            .unwrap();
1505        let new_course = create_new_course(org, "fi-FI".into());
1506        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1507            .await
1508            .unwrap();
1509        assert_eq!(
1510            course.course_language_group_id,
1511            copied_course.course_language_group_id
1512        );
1513    }
1514
1515    #[tokio::test]
1516    async fn copies_course_instances() {
1517        insert_data!(:tx, :user, :org, :course, instance: _instance);
1518        let course = crate::courses::get_course(tx.as_mut(), course)
1519            .await
1520            .unwrap();
1521        let new_course = create_new_course(org, "en-GB".into());
1522        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1523            .await
1524            .unwrap();
1525        let copied_instances =
1526            crate::course_instances::get_course_instances_for_course(tx.as_mut(), copied_course.id)
1527                .await
1528                .unwrap();
1529        assert_eq!(copied_instances.len(), 1);
1530    }
1531
1532    #[tokio::test]
1533    async fn copies_course_modules() {
1534        insert_data!(:tx, :user, :org, :course);
1535        let course = crate::courses::get_course(tx.as_mut(), course)
1536            .await
1537            .unwrap();
1538        let new_course = create_new_course(org, "pt-BR".into());
1539        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1540            .await
1541            .unwrap();
1542
1543        let original_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course.id)
1544            .await
1545            .unwrap();
1546        let copied_modules = crate::course_modules::get_by_course_id(tx.as_mut(), copied_course.id)
1547            .await
1548            .unwrap();
1549        assert_eq!(
1550            original_modules.first().unwrap().id,
1551            copied_modules.first().unwrap().copied_from.unwrap(),
1552        )
1553    }
1554
1555    #[tokio::test]
1556    async fn copies_course_chapters() {
1557        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter);
1558        let course = crate::courses::get_course(tx.as_mut(), course)
1559            .await
1560            .unwrap();
1561        let new_course = create_new_course(org, "sv-SV".into());
1562        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1563            .await
1564            .unwrap();
1565        let copied_chapters = crate::chapters::course_chapters(tx.as_mut(), copied_course.id)
1566            .await
1567            .unwrap();
1568        assert_eq!(copied_chapters.len(), 1);
1569        assert_eq!(copied_chapters.first().unwrap().copied_from, Some(chapter));
1570    }
1571
1572    #[tokio::test]
1573    async fn updates_chapter_front_pages() {
1574        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, chapter: _chapter);
1575        let course = crate::courses::get_course(tx.as_mut(), course)
1576            .await
1577            .unwrap();
1578        let new_course = create_new_course(org, "fr-CA".into());
1579        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1580            .await
1581            .unwrap();
1582        let copied_chapters = crate::chapters::course_chapters(tx.as_mut(), copied_course.id)
1583            .await
1584            .unwrap();
1585        let copied_chapter = copied_chapters.first().unwrap();
1586        let copied_chapter_front_page =
1587            crate::pages::get_page(tx.as_mut(), copied_chapter.front_page_id.unwrap())
1588                .await
1589                .unwrap();
1590        assert_eq!(copied_chapter_front_page.course_id, Some(copied_course.id));
1591    }
1592
1593    #[tokio::test]
1594    async fn copies_course_pages() {
1595        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, page: _page);
1596        let course = crate::courses::get_course(tx.as_mut(), course)
1597            .await
1598            .unwrap();
1599        let new_course = create_new_course(org, "es-US".into());
1600        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1601            .await
1602            .unwrap();
1603        let mut original_pages_by_id: HashMap<Uuid, Page> =
1604            crate::pages::get_all_by_course_id_and_visibility(
1605                tx.as_mut(),
1606                course.id,
1607                crate::pages::PageVisibility::Any,
1608            )
1609            .await
1610            .unwrap()
1611            .into_iter()
1612            .map(|page| (page.id, page))
1613            .collect();
1614        assert_eq!(original_pages_by_id.len(), 3);
1615        let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1616            tx.as_mut(),
1617            copied_course.id,
1618            crate::pages::PageVisibility::Any,
1619        )
1620        .await
1621        .unwrap();
1622        assert_eq!(copied_pages.len(), 3);
1623        copied_pages.into_iter().for_each(|copied_page| {
1624            assert!(
1625                original_pages_by_id
1626                    .remove(&copied_page.copied_from.unwrap())
1627                    .is_some()
1628            );
1629        });
1630        assert!(original_pages_by_id.is_empty());
1631    }
1632
1633    #[tokio::test]
1634    async fn updates_course_slugs_in_internal_links_in_pages_contents() {
1635        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page);
1636        let course = crate::courses::get_course(tx.as_mut(), course)
1637            .await
1638            .unwrap();
1639        crate::pages::update_page_content(
1640            tx.as_mut(),
1641            page,
1642            &serde_json::json!([{
1643                "name": "core/paragraph",
1644                "isValid": true,
1645                "clientId": "b2ecb473-38cc-4df1-84f7-45709cc63e95",
1646                "attributes": {
1647                    "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),
1648                    "dropCap":false
1649                },
1650                "innerBlocks": []
1651            }]),
1652        )
1653        .await.unwrap();
1654
1655        let new_course = create_new_course(org, "fi-FI".into());
1656        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1657            .await
1658            .unwrap();
1659
1660        let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1661            tx.as_mut(),
1662            copied_course.id,
1663            crate::pages::PageVisibility::Any,
1664        )
1665        .await
1666        .unwrap();
1667        let copied_page = copied_pages
1668            .into_iter()
1669            .find(|copied_page| copied_page.copied_from == Some(page))
1670            .unwrap();
1671        let copied_content_in_page = copied_page.content[0]["attributes"]["content"]
1672            .as_str()
1673            .unwrap();
1674        let content_with_updated_course_slug = "Internal link <a href=\"http://project-331.local/org/uh-cs/courses/copied-course\">http://project-331.local/org/uh-cs/courses/copied-course</a>";
1675        assert_eq!(copied_content_in_page, content_with_updated_course_slug);
1676    }
1677
1678    #[tokio::test]
1679    async fn updates_exercise_id_in_content() {
1680        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise);
1681        let course = crate::courses::get_course(tx.as_mut(), course)
1682            .await
1683            .unwrap();
1684        crate::pages::update_page_content(
1685            tx.as_mut(),
1686            page,
1687            &serde_json::json!([{
1688                "name": "moocfi/exercise",
1689                "isValid": true,
1690                "clientId": "b2ecb473-38cc-4df1-84f7-06709cc63e95",
1691                "attributes": {
1692                    "id": exercise,
1693                    "name": "Exercise"
1694                },
1695                "innerBlocks": []
1696            }]),
1697        )
1698        .await
1699        .unwrap();
1700        let new_course = create_new_course(org, "es-MX".into());
1701        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1702            .await
1703            .unwrap();
1704        let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1705            tx.as_mut(),
1706            copied_course.id,
1707            crate::pages::PageVisibility::Any,
1708        )
1709        .await
1710        .unwrap();
1711        let copied_page = copied_pages
1712            .into_iter()
1713            .find(|copied_page| copied_page.copied_from == Some(page))
1714            .unwrap();
1715        let copied_exercise_id_in_content =
1716            Uuid::parse_str(copied_page.content[0]["attributes"]["id"].as_str().unwrap()).unwrap();
1717        let copied_exercise =
1718            crate::exercises::get_by_id(tx.as_mut(), copied_exercise_id_in_content)
1719                .await
1720                .unwrap();
1721        assert_eq!(copied_exercise.course_id.unwrap(), copied_course.id);
1722    }
1723
1724    #[tokio::test]
1725    async fn copies_exercises_tasks_and_slides() {
1726        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise, :slide, :task);
1727        let course = crate::courses::get_course(tx.as_mut(), course)
1728            .await
1729            .unwrap();
1730        let new_course = create_new_course(org, "fi-SV".into());
1731        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1732            .await
1733            .unwrap();
1734        let copied_exercises =
1735            crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
1736                .await
1737                .unwrap();
1738        assert_eq!(copied_exercises.len(), 1);
1739        let copied_exercise = copied_exercises.first().unwrap();
1740        assert_eq!(copied_exercise.copied_from, Some(exercise));
1741        let original_exercise = crate::exercises::get_by_id(tx.as_mut(), exercise)
1742            .await
1743            .unwrap();
1744        assert_eq!(
1745            copied_exercise.max_tries_per_slide,
1746            original_exercise.max_tries_per_slide
1747        );
1748        assert_eq!(
1749            copied_exercise.limit_number_of_tries,
1750            original_exercise.limit_number_of_tries
1751        );
1752        assert_eq!(
1753            copied_exercise.needs_peer_review,
1754            original_exercise.needs_peer_review
1755        );
1756        assert_eq!(
1757            copied_exercise.use_course_default_peer_or_self_review_config,
1758            original_exercise.use_course_default_peer_or_self_review_config
1759        );
1760        let copied_slides = crate::exercise_slides::get_exercise_slides_by_exercise_id(
1761            tx.as_mut(),
1762            copied_exercise.id,
1763        )
1764        .await
1765        .unwrap();
1766        assert_eq!(copied_slides.len(), 1);
1767        let copied_slide = copied_slides.first().unwrap();
1768        let copied_tasks: Vec<ExerciseTask> =
1769            crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_id(
1770                tx.as_mut(),
1771                &copied_slide.id,
1772            )
1773            .await
1774            .unwrap();
1775        assert_eq!(copied_tasks.len(), 1);
1776        let copied_task = copied_tasks.first().unwrap();
1777        assert_eq!(copied_task.copied_from, Some(task));
1778
1779        let original_course_chapters = crate::chapters::course_chapters(tx.as_mut(), course.id)
1780            .await
1781            .unwrap();
1782        for original_chapter in original_course_chapters {
1783            for copied_exercise in &copied_exercises {
1784                assert_ne!(original_chapter.id, copied_exercise.id);
1785            }
1786        }
1787    }
1788
1789    fn create_new_course(organization_id: Uuid, language_code: String) -> NewCourse {
1790        NewCourse {
1791            name: "Copied course".to_string(),
1792            slug: "copied-course".to_string(),
1793            organization_id,
1794            language_code,
1795            teacher_in_charge_name: "Teacher".to_string(),
1796            teacher_in_charge_email: "teacher@example.com".to_string(),
1797            description: "".to_string(),
1798            is_draft: true,
1799            is_test_mode: false,
1800            is_unlisted: false,
1801            copy_user_permissions: false,
1802            is_joinable_by_code_only: false,
1803            join_code: None,
1804            ask_marketing_consent: false,
1805            flagged_answers_threshold: Some(3),
1806            can_add_chatbot: false,
1807        }
1808    }
1809}