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