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