Skip to main content

headless_lms_models/library/
copying.rs

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