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