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 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 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 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 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![], 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 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 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
502async 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 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 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 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 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 #[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 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(); 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); 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}