Skip to main content

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