headless_lms_models/library/
copying.rs

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