headless_lms_models/library/
copying.rs

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