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