headless_lms_models/library/
copying.rs

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