headless_lms_models/library/
copying.rs

1use std::collections::HashMap;
2
3use serde_json::Value;
4
5use crate::course_instances;
6use crate::course_instances::NewCourseInstance;
7use crate::course_language_groups;
8use crate::courses::Course;
9use crate::courses::NewCourse;
10use crate::courses::get_course;
11use crate::exams;
12use crate::exams::Exam;
13use crate::exams::NewExam;
14use crate::pages;
15use crate::prelude::*;
16
17use crate::ModelResult;
18
19pub async fn copy_course(
20    conn: &mut PgConnection,
21    course_id: Uuid,
22    new_course: &NewCourse,
23    same_language_group: bool,
24    user_id: Uuid,
25) -> ModelResult<Course> {
26    let mut tx = conn.begin().await?;
27    let parent_course = get_course(&mut tx, course_id).await?;
28    let course_language_group_id = if same_language_group {
29        parent_course.course_language_group_id
30    } else {
31        course_language_groups::insert(&mut tx, PKeyPolicy::Generate).await?
32    };
33
34    let copied_course = copy_course_with_language_group(
35        &mut tx,
36        course_id,
37        course_language_group_id,
38        new_course,
39        user_id,
40    )
41    .await?;
42
43    tx.commit().await?;
44
45    Ok(copied_course)
46}
47
48pub async fn copy_course_with_language_group(
49    conn: &mut PgConnection,
50    src_course_id: Uuid,
51    target_clg_id: Uuid,
52    new_course: &NewCourse,
53    user_id: Uuid,
54) -> ModelResult<Course> {
55    let parent_course = get_course(conn, src_course_id).await?;
56    let same_clg = target_clg_id == parent_course.course_language_group_id;
57
58    let mut tx = conn.begin().await?;
59
60    let copied_course = sqlx::query_as!(
61        Course,
62        r#"
63INSERT INTO courses (
64    name,
65    organization_id,
66    slug,
67    content_search_language,
68    language_code,
69    copied_from,
70    course_language_group_id,
71    is_draft,
72    base_module_completion_requires_n_submodule_completions,
73    can_add_chatbot,
74    is_unlisted,
75    is_joinable_by_code_only,
76    join_code,
77    ask_marketing_consent
78  )
79VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
80RETURNING id,
81  name,
82  created_at,
83  updated_at,
84  organization_id,
85  deleted_at,
86  slug,
87  content_search_language::text,
88  language_code,
89  copied_from,
90  course_language_group_id,
91  description,
92  is_draft,
93  is_test_mode,
94  base_module_completion_requires_n_submodule_completions,
95  can_add_chatbot,
96  is_unlisted,
97  is_joinable_by_code_only,
98  join_code,
99  ask_marketing_consent,
100  flagged_answers_threshold
101        "#,
102        new_course.name,
103        new_course.organization_id,
104        new_course.slug,
105        parent_course.content_search_language as _,
106        new_course.language_code,
107        parent_course.id,
108        target_clg_id,
109        new_course.is_draft,
110        parent_course.base_module_completion_requires_n_submodule_completions,
111        parent_course.can_add_chatbot,
112        new_course.is_unlisted,
113        new_course.is_joinable_by_code_only,
114        new_course.join_code,
115        new_course.ask_marketing_consent
116    )
117    .fetch_one(&mut *tx)
118    .await?;
119
120    copy_course_modules(&mut tx, copied_course.id, src_course_id).await?;
121    copy_course_chapters(&mut tx, copied_course.id, src_course_id).await?;
122
123    if new_course.copy_user_permissions {
124        copy_user_permissions(&mut tx, copied_course.id, src_course_id, user_id).await?;
125    }
126
127    let contents_iter =
128        copy_course_pages_and_return_contents(&mut tx, copied_course.id, src_course_id).await?;
129
130    set_chapter_front_pages(&mut tx, copied_course.id).await?;
131
132    let old_to_new_exercise_ids = map_old_exr_ids_to_new_exr_ids_for_courses(
133        &mut tx,
134        copied_course.id,
135        src_course_id,
136        target_clg_id,
137        same_clg,
138    )
139    .await?;
140
141    // update page contents exercise IDs
142    for (page_id, content) in contents_iter {
143        if let Value::Array(mut blocks) = content {
144            for block in blocks.iter_mut() {
145                if block["name"] != Value::String("moocfi/exercise".to_string()) {
146                    continue;
147                }
148                if let Value::String(old_id) = &block["attributes"]["id"] {
149                    let new_id = old_to_new_exercise_ids
150                        .get(old_id)
151                        .ok_or_else(|| {
152                            ModelError::new(
153                                ModelErrorType::Generic,
154                                "Invalid exercise id in content.".to_string(),
155                                None,
156                            )
157                        })?
158                        .to_string();
159                    block["attributes"]["id"] = Value::String(new_id);
160                }
161            }
162            sqlx::query!(
163                r#"
164UPDATE pages
165SET content = $1
166WHERE id = $2;
167"#,
168                Value::Array(blocks),
169                page_id
170            )
171            .execute(&mut *tx)
172            .await?;
173        }
174    }
175
176    let pages_contents = pages::get_all_by_course_id_and_visibility(
177        tx.as_mut(),
178        copied_course.id,
179        pages::PageVisibility::Any,
180    )
181    .await?
182    .into_iter()
183    .map(|page| (page.id, page.content))
184    .collect::<HashMap<_, _>>();
185
186    for (page_id, content) in pages_contents {
187        if let Value::Array(mut blocks) = content {
188            for block in blocks.iter_mut() {
189                if let Some(content) = block["attributes"]["content"].as_str() {
190                    if content.contains("<a href=") {
191                        block["attributes"]["content"] =
192                            Value::String(content.replace(&parent_course.slug, &new_course.slug));
193                    }
194                }
195            }
196            sqlx::query!(
197                r#"
198UPDATE pages
199SET content = $1
200WHERE id = $2;
201"#,
202                Value::Array(blocks),
203                page_id
204            )
205            .execute(&mut *tx)
206            .await?;
207        }
208    }
209
210    copy_exercise_slides(&mut tx, copied_course.id, src_course_id).await?;
211    copy_exercise_tasks(&mut tx, copied_course.id, src_course_id).await?;
212
213    course_instances::insert(
214        &mut tx,
215        PKeyPolicy::Generate,
216        NewCourseInstance {
217            course_id: copied_course.id,
218            name: None,
219            description: None,
220            support_email: None,
221            teacher_in_charge_name: &new_course.teacher_in_charge_name,
222            teacher_in_charge_email: &new_course.teacher_in_charge_email,
223            opening_time: None,
224            closing_time: None,
225        },
226    )
227    .await?;
228
229    copy_peer_or_self_review_configs(&mut tx, copied_course.id, src_course_id).await?;
230    copy_peer_or_self_review_questions(&mut tx, copied_course.id, src_course_id).await?;
231    copy_material_references(&mut tx, copied_course.id, src_course_id).await?;
232    copy_glossary_entries(&mut tx, copied_course.id, src_course_id).await?;
233
234    tx.commit().await?;
235
236    Ok(copied_course)
237}
238
239pub async fn copy_exam(
240    conn: &mut PgConnection,
241    parent_exam_id: &Uuid,
242    new_exam: &NewExam,
243) -> ModelResult<Exam> {
244    let parent_exam = exams::get(conn, *parent_exam_id).await?;
245
246    let mut tx = conn.begin().await?;
247
248    let parent_exam_fields = sqlx::query!(
249        "
250SELECT language,
251  organization_id,
252  minimum_points_treshold
253FROM exams
254WHERE id = $1
255        ",
256        parent_exam.id
257    )
258    .fetch_one(&mut *tx)
259    .await?;
260
261    // create new exam
262    let copied_exam = sqlx::query!(
263        "
264INSERT INTO exams(
265    name,
266    organization_id,
267    instructions,
268    starts_at,
269    ends_at,
270    language,
271    time_minutes,
272    minimum_points_treshold,
273    grade_manually
274  )
275VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
276RETURNING *
277        ",
278        new_exam.name,
279        parent_exam_fields.organization_id,
280        parent_exam.instructions,
281        new_exam.starts_at,
282        new_exam.ends_at,
283        parent_exam_fields.language,
284        new_exam.time_minutes,
285        parent_exam_fields.minimum_points_treshold,
286        new_exam.grade_manually,
287    )
288    .fetch_one(&mut *tx)
289    .await?;
290
291    let contents_iter =
292        copy_exam_pages_and_return_contents(&mut tx, copied_exam.id, parent_exam.id).await?;
293
294    // Copy exam exercises
295    let old_to_new_exercise_ids =
296        map_old_exr_ids_to_new_exr_ids_for_exams(&mut tx, copied_exam.id, parent_exam.id).await?;
297
298    // Replace exercise ids in page contents.
299    for (page_id, content) in contents_iter {
300        if let Value::Array(mut blocks) = content {
301            for block in blocks.iter_mut() {
302                if block["name"] != Value::String("moocfi/exercise".to_string()) {
303                    continue;
304                }
305                if let Value::String(old_id) = &block["attributes"]["id"] {
306                    let new_id = old_to_new_exercise_ids
307                        .get(old_id)
308                        .ok_or_else(|| {
309                            ModelError::new(
310                                ModelErrorType::Generic,
311                                "Invalid exercise id in content.".to_string(),
312                                None,
313                            )
314                        })?
315                        .to_string();
316                    block["attributes"]["id"] = Value::String(new_id);
317                }
318            }
319            sqlx::query!(
320                "
321UPDATE pages
322SET content = $1
323WHERE id = $2;
324                ",
325                Value::Array(blocks),
326                page_id,
327            )
328            .execute(&mut *tx)
329            .await?;
330        }
331    }
332
333    copy_exercise_slides(&mut tx, copied_exam.id, parent_exam.id).await?;
334
335    copy_exercise_tasks(&mut tx, copied_exam.id, parent_exam.id).await?;
336
337    tx.commit().await?;
338
339    let get_page_id = sqlx::query!(
340        "SELECT id AS page_id FROM pages WHERE exam_id = $1;",
341        copied_exam.id
342    )
343    .fetch_one(conn)
344    .await?;
345
346    Ok(Exam {
347        courses: vec![], // no related courses on newly copied exam
348        ends_at: copied_exam.ends_at,
349        starts_at: copied_exam.starts_at,
350        id: copied_exam.id,
351        instructions: copied_exam.instructions,
352        name: copied_exam.name,
353        time_minutes: copied_exam.time_minutes,
354        page_id: get_page_id.page_id,
355        minimum_points_treshold: copied_exam.minimum_points_treshold,
356        language: copied_exam.language.unwrap_or("en-US".to_string()),
357        grade_manually: copied_exam.grade_manually,
358    })
359}
360
361async fn copy_course_pages_and_return_contents(
362    tx: &mut PgConnection,
363    namespace_id: Uuid,
364    parent_course_id: Uuid,
365) -> ModelResult<HashMap<Uuid, Value>> {
366    // Copy course pages. At this point, exercise ids in content will point to old course's exercises.
367    let contents = sqlx::query!(
368        "
369    INSERT INTO pages (
370        id,
371        course_id,
372        content,
373        url_path,
374        title,
375        chapter_id,
376        order_number,
377        copied_from,
378        content_search_language,
379        page_language_group_id
380      )
381    SELECT uuid_generate_v5($1, id::text),
382      $1,
383      content,
384      url_path,
385      title,
386      uuid_generate_v5($1, chapter_id::text),
387      order_number,
388      id,
389      content_search_language,
390      page_language_group_id
391    FROM pages
392    WHERE (course_id = $2)
393    AND deleted_at IS NULL
394    RETURNING id,
395      content;
396        ",
397        namespace_id,
398        parent_course_id
399    )
400    .fetch_all(tx)
401    .await?
402    .into_iter()
403    .map(|record| (record.id, record.content))
404    .collect();
405
406    Ok(contents)
407}
408
409async fn copy_exam_pages_and_return_contents(
410    tx: &mut PgConnection,
411    namespace_id: Uuid,
412    parent_exam_id: Uuid,
413) -> ModelResult<HashMap<Uuid, Value>> {
414    let contents = sqlx::query!(
415        "
416    INSERT INTO pages (
417        id,
418        exam_id,
419        content,
420        url_path,
421        title,
422        chapter_id,
423        order_number,
424        copied_from,
425        content_search_language
426      )
427    SELECT uuid_generate_v5($1, id::text),
428      $1,
429      content,
430      url_path,
431      title,
432      uuid_generate_v5($1, chapter_id::text),
433      order_number,
434      id,
435      content_search_language
436    FROM pages
437    WHERE (exam_id = $2)
438    AND deleted_at IS NULL
439    RETURNING id,
440      content;
441        ",
442        namespace_id,
443        parent_exam_id
444    )
445    .fetch_all(tx)
446    .await?
447    .into_iter()
448    .map(|record| (record.id, record.content))
449    .collect();
450
451    Ok(contents)
452}
453
454async fn set_chapter_front_pages(tx: &mut PgConnection, namespace_id: Uuid) -> ModelResult<()> {
455    // Update front_page_id of chapters now that new pages exist.
456    sqlx::query!(
457        "
458    UPDATE chapters
459    SET front_page_id = uuid_generate_v5(course_id, front_page_id::text)
460    WHERE course_id = $1
461        AND front_page_id IS NOT NULL;
462            ",
463        namespace_id,
464    )
465    .execute(&mut *tx)
466    .await?;
467
468    Ok(())
469}
470
471async fn copy_course_modules(
472    tx: &mut PgConnection,
473    new_course_id: Uuid,
474    old_course_id: Uuid,
475) -> ModelResult<()> {
476    sqlx::query!(
477        "
478INSERT INTO course_modules (
479    id,
480    course_id,
481    name,
482    order_number,
483    copied_from
484  )
485SELECT uuid_generate_v5($1, id::text),
486  $1,
487  name,
488  order_number,
489  id
490FROM course_modules
491WHERE course_id = $2
492  AND deleted_at IS NULL
493        ",
494        new_course_id,
495        old_course_id,
496    )
497    .execute(&mut *tx)
498    .await?;
499    Ok(())
500}
501
502/// After this one `set_chapter_front_pages` needs to be called to get these to point to the correct front pages.
503async fn copy_course_chapters(
504    tx: &mut PgConnection,
505    namespace_id: Uuid,
506    parent_course_id: Uuid,
507) -> ModelResult<()> {
508    sqlx::query!(
509        "
510INSERT INTO chapters (
511    id,
512    name,
513    course_id,
514    chapter_number,
515    front_page_id,
516    opens_at,
517    chapter_image_path,
518    copied_from,
519    course_module_id
520  )
521SELECT uuid_generate_v5($1, id::text),
522  name,
523  $1,
524  chapter_number,
525  front_page_id,
526  opens_at,
527  chapter_image_path,
528  id,
529  uuid_generate_v5($1, course_module_id::text)
530FROM chapters
531WHERE (course_id = $2)
532AND deleted_at IS NULL;
533    ",
534        namespace_id,
535        parent_course_id
536    )
537    .execute(&mut *tx)
538    .await?;
539
540    Ok(())
541}
542
543async fn map_old_exr_ids_to_new_exr_ids_for_courses(
544    tx: &mut PgConnection,
545    new_course_id: Uuid,
546    src_course_id: Uuid,
547    target_clg_id: Uuid,
548    same_clg: bool,
549) -> ModelResult<HashMap<String, String>> {
550    let rows = sqlx::query!(
551        r#"
552WITH src AS (
553  SELECT e.*,
554    CASE
555      WHEN $4 THEN e.exercise_language_group_id
556      ELSE uuid_generate_v5($3, e.id::text)
557    END AS tgt_elg_id
558  FROM exercises e
559  WHERE e.course_id = $2
560    AND e.deleted_at IS NULL
561),
562ins_elg AS (
563  INSERT INTO exercise_language_groups (id, course_language_group_id)
564  SELECT DISTINCT tgt_elg_id,
565    $3
566  FROM src
567  WHERE NOT $4 ON CONFLICT (id) DO NOTHING
568),
569ins_exercises AS (
570  INSERT INTO exercises (
571      id,
572      course_id,
573      name,
574      deadline,
575      page_id,
576      score_maximum,
577      order_number,
578      chapter_id,
579      copied_from,
580      exercise_language_group_id,
581      max_tries_per_slide,
582      limit_number_of_tries,
583      needs_peer_review,
584      use_course_default_peer_or_self_review_config
585    )
586  SELECT uuid_generate_v5($1, src.id::text),
587    $1,
588    src.name,
589    src.deadline,
590    uuid_generate_v5($1, src.page_id::text),
591    src.score_maximum,
592    src.order_number,
593    uuid_generate_v5($1, src.chapter_id::text),
594    src.id,
595    src.tgt_elg_id,
596    src.max_tries_per_slide,
597    src.limit_number_of_tries,
598    src.needs_peer_review,
599    src.use_course_default_peer_or_self_review_config
600  FROM src
601  RETURNING id,
602    copied_from
603)
604SELECT id,
605  copied_from
606FROM ins_exercises;
607        "#,
608        new_course_id,
609        src_course_id,
610        target_clg_id,
611        same_clg,
612    )
613    .fetch_all(tx)
614    .await?;
615
616    Ok(rows
617        .into_iter()
618        .map(|r| (r.copied_from.unwrap().to_string(), r.id.to_string()))
619        .collect())
620}
621
622async fn map_old_exr_ids_to_new_exr_ids_for_exams(
623    tx: &mut PgConnection,
624    namespace_id: Uuid,
625    parent_exam_id: Uuid,
626) -> ModelResult<HashMap<String, String>> {
627    let old_to_new_exercise_ids = sqlx::query!(
628        "
629INSERT INTO exercises (
630    id,
631    exam_id,
632    name,
633    deadline,
634    page_id,
635    score_maximum,
636    order_number,
637    chapter_id,
638    copied_from,
639    max_tries_per_slide,
640    limit_number_of_tries,
641    needs_peer_review,
642    use_course_default_peer_or_self_review_config
643  )
644SELECT uuid_generate_v5($1, id::text),
645  $1,
646  name,
647  deadline,
648  uuid_generate_v5($1, page_id::text),
649  score_maximum,
650  order_number,
651  uuid_generate_v5($1, chapter_id::text),
652  id,
653  max_tries_per_slide,
654  limit_number_of_tries,
655  needs_peer_review,
656  use_course_default_peer_or_self_review_config
657FROM exercises
658WHERE exam_id = $2
659  AND deleted_at IS NULL
660RETURNING id,
661  copied_from;
662            ",
663        namespace_id,
664        parent_exam_id
665    )
666    .fetch_all(tx)
667    .await?
668    .into_iter()
669    .map(|record| {
670        Ok((
671            record
672                .copied_from
673                .ok_or_else(|| {
674                    ModelError::new(
675                        ModelErrorType::Generic,
676                        "Query failed to return valid data.".to_string(),
677                        None,
678                    )
679                })?
680                .to_string(),
681            record.id.to_string(),
682        ))
683    })
684    .collect::<ModelResult<HashMap<String, String>>>()?;
685
686    Ok(old_to_new_exercise_ids)
687}
688
689async fn copy_exercise_slides(
690    tx: &mut PgConnection,
691    namespace_id: Uuid,
692    parent_id: Uuid,
693) -> ModelResult<()> {
694    // Copy exercise slides
695    sqlx::query!(
696        "
697    INSERT INTO exercise_slides (
698        id, exercise_id, order_number
699    )
700    SELECT uuid_generate_v5($1, id::text),
701        uuid_generate_v5($1, exercise_id::text),
702        order_number
703    FROM exercise_slides
704    WHERE exercise_id IN (SELECT id FROM exercises WHERE course_id = $2 OR exam_id = $2 AND deleted_at IS NULL)
705    AND deleted_at IS NULL;
706            ",
707        namespace_id,
708        parent_id
709    )
710    .execute(&mut *tx)
711    .await?;
712
713    Ok(())
714}
715
716async fn copy_exercise_tasks(
717    tx: &mut PgConnection,
718    namespace_id: Uuid,
719    parent_id: Uuid,
720) -> ModelResult<()> {
721    // Copy exercise tasks
722    sqlx::query!(
723        "
724INSERT INTO exercise_tasks (
725    id,
726    exercise_slide_id,
727    exercise_type,
728    assignment,
729    private_spec,
730    public_spec,
731    model_solution_spec,
732    order_number,
733    copied_from
734  )
735SELECT uuid_generate_v5($1, id::text),
736  uuid_generate_v5($1, exercise_slide_id::text),
737  exercise_type,
738  assignment,
739  private_spec,
740  public_spec,
741  model_solution_spec,
742  order_number,
743  id
744FROM exercise_tasks
745WHERE exercise_slide_id IN (
746    SELECT s.id
747    FROM exercise_slides s
748      JOIN exercises e ON (e.id = s.exercise_id)
749    WHERE e.course_id = $2 OR e.exam_id = $2
750    AND e.deleted_at IS NULL
751    AND s.deleted_at IS NULL
752  )
753AND deleted_at IS NULL;
754    ",
755        namespace_id,
756        parent_id,
757    )
758    .execute(&mut *tx)
759    .await?;
760    Ok(())
761}
762
763pub async fn copy_user_permissions(
764    conn: &mut PgConnection,
765    new_course_id: Uuid,
766    old_course_id: Uuid,
767    user_id: Uuid,
768) -> ModelResult<()> {
769    sqlx::query!(
770        "
771INSERT INTO roles (
772    id,
773    user_id,
774    organization_id,
775    course_id,
776    role
777  )
778SELECT uuid_generate_v5($2, id::text),
779  user_id,
780  organization_id,
781  $2,
782  role
783FROM roles
784WHERE (course_id = $1)
785AND NOT (user_id = $3)
786AND deleted_at IS NULL;
787    ",
788        old_course_id,
789        new_course_id,
790        user_id
791    )
792    .execute(conn)
793    .await?;
794    Ok(())
795}
796
797async fn copy_peer_or_self_review_configs(
798    tx: &mut PgConnection,
799    namespace_id: Uuid,
800    parent_id: Uuid,
801) -> ModelResult<()> {
802    sqlx::query!(
803        "
804INSERT INTO peer_or_self_review_configs (
805    id,
806    course_id,
807    exercise_id,
808    peer_reviews_to_give,
809    peer_reviews_to_receive,
810    processing_strategy,
811    accepting_threshold
812  )
813SELECT uuid_generate_v5($1, posrc.id::text),
814  $1,
815  uuid_generate_v5($1, posrc.exercise_id::text),
816  posrc.peer_reviews_to_give,
817  posrc.peer_reviews_to_receive,
818  posrc.processing_strategy,
819  posrc.accepting_threshold
820FROM peer_or_self_review_configs posrc
821LEFT JOIN exercises e ON (e.id = posrc.exercise_id)
822WHERE posrc.course_id = $2
823AND posrc.deleted_at IS NULL
824AND e.deleted_at IS NULL;
825    ",
826        namespace_id,
827        parent_id,
828    )
829    .execute(&mut *tx)
830    .await?;
831    Ok(())
832}
833
834async fn copy_peer_or_self_review_questions(
835    tx: &mut PgConnection,
836    namespace_id: Uuid,
837    parent_id: Uuid,
838) -> ModelResult<()> {
839    sqlx::query!(
840        "
841INSERT INTO peer_or_self_review_questions (
842    id,
843    peer_or_self_review_config_id,
844    order_number,
845    question,
846    question_type,
847    answer_required,
848    weight
849  )
850SELECT uuid_generate_v5($1, q.id::text),
851  uuid_generate_v5($1, q.peer_or_self_review_config_id::text),
852  q.order_number,
853  q.question,
854  q.question_type,
855  q.answer_required,
856  q.weight
857FROM peer_or_self_review_questions q
858  JOIN peer_or_self_review_configs posrc ON (posrc.id = q.peer_or_self_review_config_id)
859  JOIN exercises e ON (e.id = posrc.exercise_id)
860WHERE peer_or_self_review_config_id IN (
861    SELECT id
862    FROM peer_or_self_review_configs
863    WHERE course_id = $2
864      AND deleted_at IS NULL
865  )
866  AND q.deleted_at IS NULL
867  AND e.deleted_at IS NULL
868  AND posrc.deleted_at IS NULL;
869    ",
870        namespace_id,
871        parent_id,
872    )
873    .execute(&mut *tx)
874    .await?;
875    Ok(())
876}
877
878async fn copy_material_references(
879    tx: &mut PgConnection,
880    namespace_id: Uuid,
881    parent_id: Uuid,
882) -> ModelResult<()> {
883    // Copy material references
884    sqlx::query!(
885        "
886INSERT INTO material_references (
887    citation_key,
888    course_id,
889    id,
890    reference
891)
892SELECT citation_key,
893  $1,
894  uuid_generate_v5($1, id::text),
895  reference
896FROM material_references
897WHERE course_id = $2
898AND deleted_at IS NULL;
899    ",
900        namespace_id,
901        parent_id,
902    )
903    .execute(&mut *tx)
904    .await?;
905    Ok(())
906}
907
908async fn copy_glossary_entries(
909    tx: &mut PgConnection,
910    new_course_id: Uuid,
911    old_course_id: Uuid,
912) -> ModelResult<()> {
913    sqlx::query!(
914        "
915INSERT INTO glossary (
916    id,
917    course_id,
918    term,
919    definition
920  )
921SELECT uuid_generate_v5($1, id::text),
922  $1,
923  term,
924  definition
925FROM glossary
926WHERE course_id = $2
927  AND deleted_at IS NULL;
928        ",
929        new_course_id,
930        old_course_id,
931    )
932    .execute(&mut *tx)
933    .await?;
934    Ok(())
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940    use crate::{exercise_tasks::ExerciseTask, pages::Page, test_helper::*};
941    use pretty_assertions::assert_eq;
942
943    #[tokio::test]
944    async fn elg_preserved_when_same_course_language_group() {
945        insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
946                     :chapter, :page, :exercise);
947        let original_ex = crate::exercises::get_by_id(tx.as_mut(), exercise)
948            .await
949            .unwrap();
950
951        /* copy into THE SAME CLG via same_language_group = true */
952        let new_meta = create_new_course(org, "fi-FI".into());
953        let copied_course = copy_course(tx.as_mut(), course, &new_meta, true, user)
954            .await
955            .unwrap();
956
957        let copied_ex = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
958            .await
959            .unwrap()
960            .pop()
961            .unwrap();
962
963        assert_eq!(
964            original_ex.exercise_language_group_id, copied_ex.exercise_language_group_id,
965            "ELG must stay identical when CLG is unchanged"
966        );
967    }
968
969    /// 2.  When we copy to a *different* CLG twice **with the same target id**,
970    ///     every exercise must get the SAME deterministic ELG each time.
971    #[tokio::test]
972    async fn elg_deterministic_when_reusing_target_clg() {
973        insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
974                     :chapter, :page, exercise: _e);
975
976        // Pre-create a brand-new CLG that both copies will use
977        let reusable_clg = course_language_groups::insert(tx.as_mut(), PKeyPolicy::Generate)
978            .await
979            .unwrap();
980
981        let meta1 = create_new_course(org, "en-US".into());
982        let copy1 =
983            copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta1, user)
984                .await
985                .unwrap();
986
987        let meta2 = {
988            let mut nc = create_new_course(org, "pt-BR".into());
989            nc.slug = "copied-course-2".into(); // ensure uniqueness
990            nc
991        };
992        let copy2 =
993            copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta2, user)
994                .await
995                .unwrap();
996
997        let ex1 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy1.id)
998            .await
999            .unwrap()
1000            .pop()
1001            .unwrap();
1002        let ex2 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy2.id)
1003            .await
1004            .unwrap()
1005            .pop()
1006            .unwrap();
1007
1008        assert_ne!(ex1.course_id, ex2.course_id); // different copies
1009        assert_eq!(
1010            ex1.exercise_language_group_id, ex2.exercise_language_group_id,
1011            "ELG must be deterministic for the same (target CLG, src exercise)"
1012        );
1013    }
1014
1015    #[tokio::test]
1016    async fn copies_course_as_different_course_language_group() {
1017        insert_data!(:tx, :user, :org, :course);
1018        let course = crate::courses::get_course(tx.as_mut(), course)
1019            .await
1020            .unwrap();
1021        let new_course = create_new_course(org, "en-US".into());
1022        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, false, user)
1023            .await
1024            .unwrap();
1025        assert_ne!(
1026            course.course_language_group_id,
1027            copied_course.course_language_group_id
1028        );
1029    }
1030
1031    #[tokio::test]
1032    async fn copies_course_as_same_course_language_group() {
1033        insert_data!(:tx, :user, :org, :course);
1034        let course = crate::courses::get_course(tx.as_mut(), course)
1035            .await
1036            .unwrap();
1037        let new_course = create_new_course(org, "fi-FI".into());
1038        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1039            .await
1040            .unwrap();
1041        assert_eq!(
1042            course.course_language_group_id,
1043            copied_course.course_language_group_id
1044        );
1045    }
1046
1047    #[tokio::test]
1048    async fn copies_course_instances() {
1049        insert_data!(:tx, :user, :org, :course, instance: _instance);
1050        let course = crate::courses::get_course(tx.as_mut(), course)
1051            .await
1052            .unwrap();
1053        let new_course = create_new_course(org, "en-GB".into());
1054        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1055            .await
1056            .unwrap();
1057        let copied_instances =
1058            crate::course_instances::get_course_instances_for_course(tx.as_mut(), copied_course.id)
1059                .await
1060                .unwrap();
1061        assert_eq!(copied_instances.len(), 1);
1062    }
1063
1064    #[tokio::test]
1065    async fn copies_course_modules() {
1066        insert_data!(:tx, :user, :org, :course);
1067        let course = crate::courses::get_course(tx.as_mut(), course)
1068            .await
1069            .unwrap();
1070        let new_course = create_new_course(org, "pt-BR".into());
1071        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1072            .await
1073            .unwrap();
1074
1075        let original_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course.id)
1076            .await
1077            .unwrap();
1078        let copied_modules = crate::course_modules::get_by_course_id(tx.as_mut(), copied_course.id)
1079            .await
1080            .unwrap();
1081        assert_eq!(
1082            original_modules.first().unwrap().id,
1083            copied_modules.first().unwrap().copied_from.unwrap(),
1084        )
1085    }
1086
1087    #[tokio::test]
1088    async fn copies_course_chapters() {
1089        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter);
1090        let course = crate::courses::get_course(tx.as_mut(), course)
1091            .await
1092            .unwrap();
1093        let new_course = create_new_course(org, "sv-SV".into());
1094        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1095            .await
1096            .unwrap();
1097        let copied_chapters = crate::chapters::course_chapters(tx.as_mut(), copied_course.id)
1098            .await
1099            .unwrap();
1100        assert_eq!(copied_chapters.len(), 1);
1101        assert_eq!(copied_chapters.first().unwrap().copied_from, Some(chapter));
1102    }
1103
1104    #[tokio::test]
1105    async fn updates_chapter_front_pages() {
1106        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, chapter: _chapter);
1107        let course = crate::courses::get_course(tx.as_mut(), course)
1108            .await
1109            .unwrap();
1110        let new_course = create_new_course(org, "fr-CA".into());
1111        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1112            .await
1113            .unwrap();
1114        let copied_chapters = crate::chapters::course_chapters(tx.as_mut(), copied_course.id)
1115            .await
1116            .unwrap();
1117        let copied_chapter = copied_chapters.first().unwrap();
1118        let copied_chapter_front_page =
1119            crate::pages::get_page(tx.as_mut(), copied_chapter.front_page_id.unwrap())
1120                .await
1121                .unwrap();
1122        assert_eq!(copied_chapter_front_page.course_id, Some(copied_course.id));
1123    }
1124
1125    #[tokio::test]
1126    async fn copies_course_pages() {
1127        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, page: _page);
1128        let course = crate::courses::get_course(tx.as_mut(), course)
1129            .await
1130            .unwrap();
1131        let new_course = create_new_course(org, "es-US".into());
1132        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1133            .await
1134            .unwrap();
1135        let mut original_pages_by_id: HashMap<Uuid, Page> =
1136            crate::pages::get_all_by_course_id_and_visibility(
1137                tx.as_mut(),
1138                course.id,
1139                crate::pages::PageVisibility::Any,
1140            )
1141            .await
1142            .unwrap()
1143            .into_iter()
1144            .map(|page| (page.id, page))
1145            .collect();
1146        assert_eq!(original_pages_by_id.len(), 3);
1147        let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1148            tx.as_mut(),
1149            copied_course.id,
1150            crate::pages::PageVisibility::Any,
1151        )
1152        .await
1153        .unwrap();
1154        assert_eq!(copied_pages.len(), 3);
1155        copied_pages.into_iter().for_each(|copied_page| {
1156            assert!(
1157                original_pages_by_id
1158                    .remove(&copied_page.copied_from.unwrap())
1159                    .is_some()
1160            );
1161        });
1162        assert!(original_pages_by_id.is_empty());
1163    }
1164
1165    #[tokio::test]
1166    async fn updates_course_slugs_in_internal_links_in_pages_contents() {
1167        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page);
1168        let course = crate::courses::get_course(tx.as_mut(), course)
1169            .await
1170            .unwrap();
1171        crate::pages::update_page_content(
1172            tx.as_mut(),
1173            page,
1174            &serde_json::json!([{
1175                "name": "core/paragraph",
1176                "isValid": true,
1177                "clientId": "b2ecb473-38cc-4df1-84f7-45709cc63e95",
1178                "attributes": {
1179                    "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),
1180                    "dropCap":false
1181                },
1182                "innerBlocks": []
1183            }]),
1184        )
1185        .await.unwrap();
1186
1187        let new_course = create_new_course(org, "fi-FI".into());
1188        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1189            .await
1190            .unwrap();
1191
1192        let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1193            tx.as_mut(),
1194            copied_course.id,
1195            crate::pages::PageVisibility::Any,
1196        )
1197        .await
1198        .unwrap();
1199        let copied_page = copied_pages
1200            .into_iter()
1201            .find(|copied_page| copied_page.copied_from == Some(page))
1202            .unwrap();
1203        let copied_content_in_page = copied_page.content[0]["attributes"]["content"]
1204            .as_str()
1205            .unwrap();
1206        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>";
1207        assert_eq!(copied_content_in_page, content_with_updated_course_slug);
1208    }
1209
1210    #[tokio::test]
1211    async fn updates_exercise_id_in_content() {
1212        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise);
1213        let course = crate::courses::get_course(tx.as_mut(), course)
1214            .await
1215            .unwrap();
1216        crate::pages::update_page_content(
1217            tx.as_mut(),
1218            page,
1219            &serde_json::json!([{
1220                "name": "moocfi/exercise",
1221                "isValid": true,
1222                "clientId": "b2ecb473-38cc-4df1-84f7-06709cc63e95",
1223                "attributes": {
1224                    "id": exercise,
1225                    "name": "Exercise"
1226                },
1227                "innerBlocks": []
1228            }]),
1229        )
1230        .await
1231        .unwrap();
1232        let new_course = create_new_course(org, "es-MX".into());
1233        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1234            .await
1235            .unwrap();
1236        let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1237            tx.as_mut(),
1238            copied_course.id,
1239            crate::pages::PageVisibility::Any,
1240        )
1241        .await
1242        .unwrap();
1243        let copied_page = copied_pages
1244            .into_iter()
1245            .find(|copied_page| copied_page.copied_from == Some(page))
1246            .unwrap();
1247        let copied_exercise_id_in_content =
1248            Uuid::parse_str(copied_page.content[0]["attributes"]["id"].as_str().unwrap()).unwrap();
1249        let copied_exercise =
1250            crate::exercises::get_by_id(tx.as_mut(), copied_exercise_id_in_content)
1251                .await
1252                .unwrap();
1253        assert_eq!(copied_exercise.course_id.unwrap(), copied_course.id);
1254    }
1255
1256    #[tokio::test]
1257    async fn copies_exercises_tasks_and_slides() {
1258        insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise, :slide, :task);
1259        let course = crate::courses::get_course(tx.as_mut(), course)
1260            .await
1261            .unwrap();
1262        let new_course = create_new_course(org, "fi-SV".into());
1263        let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1264            .await
1265            .unwrap();
1266        let copied_exercises =
1267            crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
1268                .await
1269                .unwrap();
1270        assert_eq!(copied_exercises.len(), 1);
1271        let copied_exercise = copied_exercises.first().unwrap();
1272        assert_eq!(copied_exercise.copied_from, Some(exercise));
1273        let original_exercise = crate::exercises::get_by_id(tx.as_mut(), exercise)
1274            .await
1275            .unwrap();
1276        assert_eq!(
1277            copied_exercise.max_tries_per_slide,
1278            original_exercise.max_tries_per_slide
1279        );
1280        assert_eq!(
1281            copied_exercise.limit_number_of_tries,
1282            original_exercise.limit_number_of_tries
1283        );
1284        assert_eq!(
1285            copied_exercise.needs_peer_review,
1286            original_exercise.needs_peer_review
1287        );
1288        assert_eq!(
1289            copied_exercise.use_course_default_peer_or_self_review_config,
1290            original_exercise.use_course_default_peer_or_self_review_config
1291        );
1292        let copied_slides = crate::exercise_slides::get_exercise_slides_by_exercise_id(
1293            tx.as_mut(),
1294            copied_exercise.id,
1295        )
1296        .await
1297        .unwrap();
1298        assert_eq!(copied_slides.len(), 1);
1299        let copied_slide = copied_slides.first().unwrap();
1300        let copied_tasks: Vec<ExerciseTask> =
1301            crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_id(
1302                tx.as_mut(),
1303                &copied_slide.id,
1304            )
1305            .await
1306            .unwrap();
1307        assert_eq!(copied_tasks.len(), 1);
1308        let copied_task = copied_tasks.first().unwrap();
1309        assert_eq!(copied_task.copied_from, Some(task));
1310
1311        let original_course_chapters = crate::chapters::course_chapters(tx.as_mut(), course.id)
1312            .await
1313            .unwrap();
1314        for original_chapter in original_course_chapters {
1315            for copied_exercise in &copied_exercises {
1316                assert_ne!(original_chapter.id, copied_exercise.id);
1317            }
1318        }
1319    }
1320
1321    fn create_new_course(organization_id: Uuid, language_code: String) -> NewCourse {
1322        NewCourse {
1323            name: "Copied course".to_string(),
1324            slug: "copied-course".to_string(),
1325            organization_id,
1326            language_code,
1327            teacher_in_charge_name: "Teacher".to_string(),
1328            teacher_in_charge_email: "teacher@example.com".to_string(),
1329            description: "".to_string(),
1330            is_draft: true,
1331            is_test_mode: false,
1332            is_unlisted: false,
1333            copy_user_permissions: false,
1334            is_joinable_by_code_only: false,
1335            join_code: None,
1336            ask_marketing_consent: false,
1337            flagged_answers_threshold: Some(3),
1338        }
1339    }
1340}