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