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                    && content.contains("<a href=")
215                {
216                    block["attributes"]["content"] =
217                        Value::String(content.replace(&parent_course.slug, &new_course.slug));
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    use_tools
1159  )
1160SELECT
1161  uuid_generate_v5($1, id::text),
1162  $1,
1163  chatbot_name,
1164  initial_message,
1165  prompt,
1166  use_azure_search,
1167  maintain_azure_search_index,
1168  use_semantic_reranking,
1169  hide_citations,
1170  temperature,
1171  top_p,
1172  presence_penalty,
1173  frequency_penalty,
1174  response_max_tokens,
1175  daily_tokens_per_user,
1176  weekly_tokens_per_user,
1177  default_chatbot,
1178  enabled_to_students,
1179  model_id,
1180  thinking_model,
1181  use_tools
1182FROM chatbot_configurations
1183WHERE course_id = $2
1184  AND deleted_at IS NULL;
1185        ",
1186        new_course_id,
1187        old_course_id
1188    )
1189    .execute(&mut *tx)
1190    .await?;
1191    Ok(())
1192}
1193
1194async fn copy_cheater_thresholds(
1195    tx: &mut PgConnection,
1196    new_course_id: Uuid,
1197    old_course_id: Uuid,
1198) -> ModelResult<()> {
1199    let old_default_module =
1200        crate::course_modules::get_default_by_course_id(tx, old_course_id).await?;
1201    let new_default_module =
1202        crate::course_modules::get_default_by_course_id(tx, new_course_id).await?;
1203
1204    sqlx::query!(
1205        "
1206INSERT INTO cheater_thresholds (id, course_module_id, duration_seconds)
1207SELECT
1208  uuid_generate_v5($1, id::text),
1209  $2,
1210  duration_seconds
1211FROM cheater_thresholds
1212WHERE course_module_id = $3
1213  AND deleted_at IS NULL;
1214        ",
1215        new_course_id,
1216        new_default_module.id,
1217        old_default_module.id
1218    )
1219    .execute(&mut *tx)
1220    .await?;
1221    Ok(())
1222}
1223
1224async fn copy_course_custom_privacy_policy_checkbox_texts(
1225    tx: &mut PgConnection,
1226    new_course_id: Uuid,
1227    old_course_id: Uuid,
1228) -> ModelResult<()> {
1229    sqlx::query!(
1230        "
1231INSERT INTO course_custom_privacy_policy_checkbox_texts (id, course_id, text_slug, text_html)
1232SELECT uuid_generate_v5($1, id::text),
1233  $1,
1234  text_slug,
1235  text_html
1236FROM course_custom_privacy_policy_checkbox_texts
1237WHERE course_id = $2
1238  AND deleted_at IS NULL;
1239        ",
1240        new_course_id,
1241        old_course_id
1242    )
1243    .execute(&mut *tx)
1244    .await?;
1245    Ok(())
1246}
1247
1248async fn copy_exercise_repositories(
1249    tx: &mut PgConnection,
1250    new_course_id: Uuid,
1251    old_course_id: Uuid,
1252) -> ModelResult<()> {
1253    sqlx::query!(
1254        "
1255INSERT INTO exercise_repositories (
1256    id,
1257    course_id,
1258    url,
1259    deploy_key,
1260    public_key,
1261    STATUS,
1262    error_message
1263  )
1264SELECT uuid_generate_v5($1, id::text),
1265  $1,
1266  url,
1267  deploy_key,
1268  public_key,
1269  STATUS,
1270  error_message
1271FROM exercise_repositories
1272WHERE course_id = $2
1273  AND deleted_at IS NULL;
1274        ",
1275        new_course_id,
1276        old_course_id
1277    )
1278    .execute(&mut *tx)
1279    .await?;
1280    Ok(())
1281}
1282
1283async fn copy_partners_blocks(
1284    tx: &mut PgConnection,
1285    new_course_id: Uuid,
1286    old_course_id: Uuid,
1287) -> ModelResult<()> {
1288    sqlx::query!(
1289        "
1290INSERT INTO partners_blocks (id, course_id, content)
1291SELECT uuid_generate_v5($1, id::text),
1292  $1,
1293  content
1294FROM partners_blocks
1295WHERE course_id = $2
1296  AND deleted_at IS NULL;
1297        ",
1298        new_course_id,
1299        old_course_id
1300    )
1301    .execute(&mut *tx)
1302    .await?;
1303    Ok(())
1304}
1305
1306async fn copy_privacy_links(
1307    tx: &mut PgConnection,
1308    new_course_id: Uuid,
1309    old_course_id: Uuid,
1310) -> ModelResult<()> {
1311    sqlx::query!(
1312        "
1313INSERT INTO privacy_links (id, course_id, url, title)
1314SELECT uuid_generate_v5($1, id::text),
1315  $1,
1316  url,
1317  title
1318FROM privacy_links
1319WHERE course_id = $2
1320  AND deleted_at IS NULL;
1321        ",
1322        new_course_id,
1323        old_course_id
1324    )
1325    .execute(&mut *tx)
1326    .await?;
1327    Ok(())
1328}
1329
1330async fn copy_research_consent_forms_and_questions(
1331    tx: &mut PgConnection,
1332    new_course_id: Uuid,
1333    old_course_id: Uuid,
1334) -> ModelResult<()> {
1335    sqlx::query!(
1336        "
1337INSERT INTO course_specific_research_consent_forms (id, course_id, content)
1338SELECT uuid_generate_v5($1, id::text),
1339  $1,
1340  content
1341FROM course_specific_research_consent_forms
1342WHERE course_id = $2
1343  AND deleted_at IS NULL;
1344        ",
1345        new_course_id,
1346        old_course_id
1347    )
1348    .execute(&mut *tx)
1349    .await?;
1350
1351    sqlx::query!(
1352        "
1353INSERT INTO course_specific_consent_form_questions (
1354    id,
1355    course_id,
1356    research_consent_form_id,
1357    question
1358  )
1359SELECT uuid_generate_v5($1, id::text),
1360  $1,
1361  uuid_generate_v5($1, research_consent_form_id::text),
1362  question
1363FROM course_specific_consent_form_questions
1364WHERE course_id = $2
1365  AND deleted_at IS NULL;
1366        ",
1367        new_course_id,
1368        old_course_id
1369    )
1370    .execute(&mut *tx)
1371    .await?;
1372
1373    Ok(())
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378    use super::*;
1379    use crate::{exercise_tasks::ExerciseTask, pages::Page, test_helper::*};
1380    use pretty_assertions::assert_eq;
1381
1382    #[tokio::test]
1383    async fn elg_preserved_when_same_course_language_group() {
1384        insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
1385                     :chapter, :page, :exercise);
1386        let original_ex = crate::exercises::get_by_id(tx.as_mut(), exercise)
1387            .await
1388            .unwrap();
1389
1390        /* copy into THE SAME CLG via same_language_group = true */
1391        let new_meta = create_new_course(org, "fi-FI".into());
1392        let copied_course = copy_course(tx.as_mut(), course, &new_meta, true, user)
1393            .await
1394            .unwrap();
1395
1396        let copied_ex = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
1397            .await
1398            .unwrap()
1399            .pop()
1400            .unwrap();
1401
1402        assert_eq!(
1403            original_ex.exercise_language_group_id, copied_ex.exercise_language_group_id,
1404            "ELG must stay identical when CLG is unchanged"
1405        );
1406    }
1407
1408    /// 2.  When we copy to a *different* CLG twice **with the same target id**,
1409    ///     every exercise must get the SAME deterministic ELG each time.
1410    #[tokio::test]
1411    async fn elg_deterministic_when_reusing_target_clg() {
1412        insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
1413                     :chapter, :page, exercise: _e);
1414
1415        // Pre-create a brand-new CLG that both copies will use
1416        let reusable_clg = course_language_groups::insert(tx.as_mut(), PKeyPolicy::Generate)
1417            .await
1418            .unwrap();
1419
1420        let meta1 = create_new_course(org, "en-US".into());
1421        let copy1 =
1422            copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta1, user)
1423                .await
1424                .unwrap();
1425
1426        let meta2 = {
1427            let mut nc = create_new_course(org, "pt-BR".into());
1428            nc.slug = "copied-course-2".into(); // ensure uniqueness
1429            nc
1430        };
1431        let copy2 =
1432            copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta2, user)
1433                .await
1434                .unwrap();
1435
1436        let ex1 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy1.id)
1437            .await
1438            .unwrap()
1439            .pop()
1440            .unwrap();
1441        let ex2 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy2.id)
1442            .await
1443            .unwrap()
1444            .pop()
1445            .unwrap();
1446
1447        assert_ne!(ex1.course_id, ex2.course_id); // different copies
1448        assert_eq!(
1449            ex1.exercise_language_group_id, ex2.exercise_language_group_id,
1450            "ELG must be deterministic for the same (target CLG, src exercise)"
1451        );
1452    }
1453
1454    #[tokio::test]
1455    async fn copies_course_as_different_course_language_group() {
1456        insert_data!(:tx, :user, :org, :course);
1457        let course = crate::courses::get_course(tx.as_mut(), course)
1458            .await
1459            .unwrap();
1460        let new_course = create_new_course(org, "en-US".into());
1461        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, false, user)
1462            .await
1463            .unwrap();
1464        assert_ne!(
1465            course.course_language_group_id,
1466            copied_course.course_language_group_id
1467        );
1468    }
1469
1470    #[tokio::test]
1471    async fn copies_course_as_same_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, "fi-FI".into());
1477        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1478            .await
1479            .unwrap();
1480        assert_eq!(
1481            course.course_language_group_id,
1482            copied_course.course_language_group_id
1483        );
1484    }
1485
1486    #[tokio::test]
1487    async fn copies_course_instances() {
1488        insert_data!(:tx, :user, :org, :course, instance: _instance);
1489        let course = crate::courses::get_course(tx.as_mut(), course)
1490            .await
1491            .unwrap();
1492        let new_course = create_new_course(org, "en-GB".into());
1493        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1494            .await
1495            .unwrap();
1496        let copied_instances =
1497            crate::course_instances::get_course_instances_for_course(tx.as_mut(), copied_course.id)
1498                .await
1499                .unwrap();
1500        assert_eq!(copied_instances.len(), 1);
1501    }
1502
1503    #[tokio::test]
1504    async fn copies_course_modules() {
1505        insert_data!(:tx, :user, :org, :course);
1506        let course = crate::courses::get_course(tx.as_mut(), course)
1507            .await
1508            .unwrap();
1509        let new_course = create_new_course(org, "pt-BR".into());
1510        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1511            .await
1512            .unwrap();
1513
1514        let original_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course.id)
1515            .await
1516            .unwrap();
1517        let copied_modules = crate::course_modules::get_by_course_id(tx.as_mut(), copied_course.id)
1518            .await
1519            .unwrap();
1520        assert_eq!(
1521            original_modules.first().unwrap().id,
1522            copied_modules.first().unwrap().copied_from.unwrap(),
1523        )
1524    }
1525
1526    #[tokio::test]
1527    async fn copies_course_chapters() {
1528        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter);
1529        let course = crate::courses::get_course(tx.as_mut(), course)
1530            .await
1531            .unwrap();
1532        let new_course = create_new_course(org, "sv-SV".into());
1533        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1534            .await
1535            .unwrap();
1536        let copied_chapters = crate::chapters::course_chapters(tx.as_mut(), copied_course.id)
1537            .await
1538            .unwrap();
1539        assert_eq!(copied_chapters.len(), 1);
1540        assert_eq!(copied_chapters.first().unwrap().copied_from, Some(chapter));
1541    }
1542
1543    #[tokio::test]
1544    async fn updates_chapter_front_pages() {
1545        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, chapter: _chapter);
1546        let course = crate::courses::get_course(tx.as_mut(), course)
1547            .await
1548            .unwrap();
1549        let new_course = create_new_course(org, "fr-CA".into());
1550        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1551            .await
1552            .unwrap();
1553        let copied_chapters = crate::chapters::course_chapters(tx.as_mut(), copied_course.id)
1554            .await
1555            .unwrap();
1556        let copied_chapter = copied_chapters.first().unwrap();
1557        let copied_chapter_front_page =
1558            crate::pages::get_page(tx.as_mut(), copied_chapter.front_page_id.unwrap())
1559                .await
1560                .unwrap();
1561        assert_eq!(copied_chapter_front_page.course_id, Some(copied_course.id));
1562    }
1563
1564    #[tokio::test]
1565    async fn copies_course_pages() {
1566        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, page: _page);
1567        let course = crate::courses::get_course(tx.as_mut(), course)
1568            .await
1569            .unwrap();
1570        let new_course = create_new_course(org, "es-US".into());
1571        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1572            .await
1573            .unwrap();
1574        let mut original_pages_by_id: HashMap<Uuid, Page> =
1575            crate::pages::get_all_by_course_id_and_visibility(
1576                tx.as_mut(),
1577                course.id,
1578                crate::pages::PageVisibility::Any,
1579            )
1580            .await
1581            .unwrap()
1582            .into_iter()
1583            .map(|page| (page.id, page))
1584            .collect();
1585        assert_eq!(original_pages_by_id.len(), 3);
1586        let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1587            tx.as_mut(),
1588            copied_course.id,
1589            crate::pages::PageVisibility::Any,
1590        )
1591        .await
1592        .unwrap();
1593        assert_eq!(copied_pages.len(), 3);
1594        copied_pages.into_iter().for_each(|copied_page| {
1595            assert!(
1596                original_pages_by_id
1597                    .remove(&copied_page.copied_from.unwrap())
1598                    .is_some()
1599            );
1600        });
1601        assert!(original_pages_by_id.is_empty());
1602    }
1603
1604    #[tokio::test]
1605    async fn updates_course_slugs_in_internal_links_in_pages_contents() {
1606        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page);
1607        let course = crate::courses::get_course(tx.as_mut(), course)
1608            .await
1609            .unwrap();
1610        crate::pages::update_page_content(
1611            tx.as_mut(),
1612            page,
1613            &serde_json::json!([{
1614                "name": "core/paragraph",
1615                "isValid": true,
1616                "clientId": "b2ecb473-38cc-4df1-84f7-45709cc63e95",
1617                "attributes": {
1618                    "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),
1619                    "dropCap":false
1620                },
1621                "innerBlocks": []
1622            }]),
1623        )
1624        .await.unwrap();
1625
1626        let new_course = create_new_course(org, "fi-FI".into());
1627        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1628            .await
1629            .unwrap();
1630
1631        let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1632            tx.as_mut(),
1633            copied_course.id,
1634            crate::pages::PageVisibility::Any,
1635        )
1636        .await
1637        .unwrap();
1638        let copied_page = copied_pages
1639            .into_iter()
1640            .find(|copied_page| copied_page.copied_from == Some(page))
1641            .unwrap();
1642        let copied_content_in_page = copied_page.content[0]["attributes"]["content"]
1643            .as_str()
1644            .unwrap();
1645        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>";
1646        assert_eq!(copied_content_in_page, content_with_updated_course_slug);
1647    }
1648
1649    #[tokio::test]
1650    async fn updates_exercise_id_in_content() {
1651        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise);
1652        let course = crate::courses::get_course(tx.as_mut(), course)
1653            .await
1654            .unwrap();
1655        crate::pages::update_page_content(
1656            tx.as_mut(),
1657            page,
1658            &serde_json::json!([{
1659                "name": "moocfi/exercise",
1660                "isValid": true,
1661                "clientId": "b2ecb473-38cc-4df1-84f7-06709cc63e95",
1662                "attributes": {
1663                    "id": exercise,
1664                    "name": "Exercise"
1665                },
1666                "innerBlocks": []
1667            }]),
1668        )
1669        .await
1670        .unwrap();
1671        let new_course = create_new_course(org, "es-MX".into());
1672        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1673            .await
1674            .unwrap();
1675        let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1676            tx.as_mut(),
1677            copied_course.id,
1678            crate::pages::PageVisibility::Any,
1679        )
1680        .await
1681        .unwrap();
1682        let copied_page = copied_pages
1683            .into_iter()
1684            .find(|copied_page| copied_page.copied_from == Some(page))
1685            .unwrap();
1686        let copied_exercise_id_in_content =
1687            Uuid::parse_str(copied_page.content[0]["attributes"]["id"].as_str().unwrap()).unwrap();
1688        let copied_exercise =
1689            crate::exercises::get_by_id(tx.as_mut(), copied_exercise_id_in_content)
1690                .await
1691                .unwrap();
1692        assert_eq!(copied_exercise.course_id.unwrap(), copied_course.id);
1693    }
1694
1695    #[tokio::test]
1696    async fn copies_exercises_tasks_and_slides() {
1697        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise, :slide, :task);
1698        let course = crate::courses::get_course(tx.as_mut(), course)
1699            .await
1700            .unwrap();
1701        let new_course = create_new_course(org, "fi-SV".into());
1702        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1703            .await
1704            .unwrap();
1705        let copied_exercises =
1706            crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
1707                .await
1708                .unwrap();
1709        assert_eq!(copied_exercises.len(), 1);
1710        let copied_exercise = copied_exercises.first().unwrap();
1711        assert_eq!(copied_exercise.copied_from, Some(exercise));
1712        let original_exercise = crate::exercises::get_by_id(tx.as_mut(), exercise)
1713            .await
1714            .unwrap();
1715        assert_eq!(
1716            copied_exercise.max_tries_per_slide,
1717            original_exercise.max_tries_per_slide
1718        );
1719        assert_eq!(
1720            copied_exercise.limit_number_of_tries,
1721            original_exercise.limit_number_of_tries
1722        );
1723        assert_eq!(
1724            copied_exercise.needs_peer_review,
1725            original_exercise.needs_peer_review
1726        );
1727        assert_eq!(
1728            copied_exercise.use_course_default_peer_or_self_review_config,
1729            original_exercise.use_course_default_peer_or_self_review_config
1730        );
1731        let copied_slides = crate::exercise_slides::get_exercise_slides_by_exercise_id(
1732            tx.as_mut(),
1733            copied_exercise.id,
1734        )
1735        .await
1736        .unwrap();
1737        assert_eq!(copied_slides.len(), 1);
1738        let copied_slide = copied_slides.first().unwrap();
1739        let copied_tasks: Vec<ExerciseTask> =
1740            crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_id(
1741                tx.as_mut(),
1742                &copied_slide.id,
1743            )
1744            .await
1745            .unwrap();
1746        assert_eq!(copied_tasks.len(), 1);
1747        let copied_task = copied_tasks.first().unwrap();
1748        assert_eq!(copied_task.copied_from, Some(task));
1749
1750        let original_course_chapters = crate::chapters::course_chapters(tx.as_mut(), course.id)
1751            .await
1752            .unwrap();
1753        for original_chapter in original_course_chapters {
1754            for copied_exercise in &copied_exercises {
1755                assert_ne!(original_chapter.id, copied_exercise.id);
1756            }
1757        }
1758    }
1759
1760    fn create_new_course(organization_id: Uuid, language_code: String) -> NewCourse {
1761        NewCourse {
1762            name: "Copied course".to_string(),
1763            slug: "copied-course".to_string(),
1764            organization_id,
1765            language_code,
1766            teacher_in_charge_name: "Teacher".to_string(),
1767            teacher_in_charge_email: "teacher@example.com".to_string(),
1768            description: "".to_string(),
1769            is_draft: true,
1770            is_test_mode: false,
1771            is_unlisted: false,
1772            copy_user_permissions: false,
1773            is_joinable_by_code_only: false,
1774            join_code: None,
1775            ask_marketing_consent: false,
1776            flagged_answers_threshold: Some(3),
1777            can_add_chatbot: false,
1778        }
1779    }
1780}