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 )
668 SELECT uuid_generate_v5($1, src.id::text),
669 $1,
670 src.name,
671 src.deadline,
672 uuid_generate_v5($1, src.page_id::text),
673 src.score_maximum,
674 src.order_number,
675 uuid_generate_v5($1, src.chapter_id::text),
676 src.id,
677 src.tgt_elg_id,
678 src.max_tries_per_slide,
679 src.limit_number_of_tries,
680 src.needs_peer_review,
681 src.use_course_default_peer_or_self_review_config,
682 src.needs_self_review
683 FROM src
684 RETURNING id,
685 copied_from
686)
687SELECT id,
688 copied_from
689FROM ins_exercises;
690 "#,
691 new_course_id,
692 src_course_id,
693 target_clg_id,
694 same_clg,
695 )
696 .fetch_all(tx)
697 .await?;
698
699 rows.into_iter()
700 .map(|r| {
701 r.copied_from
702 .ok_or_else(|| {
703 ModelError::new(
704 ModelErrorType::Database,
705 "copied_from should always be set from INSERT statement".to_string(),
706 None,
707 )
708 })
709 .map(|copied_from| (copied_from.to_string(), r.id.to_string()))
710 })
711 .collect::<ModelResult<Vec<_>>>()
712 .map(|vec| vec.into_iter().collect())
713}
714
715async fn map_old_exr_ids_to_new_exr_ids_for_exams(
716 tx: &mut PgConnection,
717 namespace_id: Uuid,
718 parent_exam_id: Uuid,
719) -> ModelResult<HashMap<String, String>> {
720 let old_to_new_exercise_ids = sqlx::query!(
721 "
722INSERT INTO exercises (
723 id,
724 exam_id,
725 name,
726 deadline,
727 page_id,
728 score_maximum,
729 order_number,
730 chapter_id,
731 copied_from,
732 max_tries_per_slide,
733 limit_number_of_tries,
734 needs_peer_review,
735 use_course_default_peer_or_self_review_config,
736 needs_self_review
737 )
738SELECT uuid_generate_v5($1, id::text),
739 $1,
740 name,
741 deadline,
742 uuid_generate_v5($1, page_id::text),
743 score_maximum,
744 order_number,
745 NULL,
746 id,
747 max_tries_per_slide,
748 limit_number_of_tries,
749 needs_peer_review,
750 use_course_default_peer_or_self_review_config,
751 needs_self_review
752FROM exercises
753WHERE exam_id = $2
754 AND deleted_at IS NULL
755RETURNING id,
756 copied_from;
757 ",
758 namespace_id,
759 parent_exam_id
760 )
761 .fetch_all(tx)
762 .await?
763 .into_iter()
764 .map(|record| {
765 Ok((
766 record
767 .copied_from
768 .ok_or_else(|| {
769 ModelError::new(
770 ModelErrorType::Generic,
771 "Query failed to return valid data.".to_string(),
772 None,
773 )
774 })?
775 .to_string(),
776 record.id.to_string(),
777 ))
778 })
779 .collect::<ModelResult<HashMap<String, String>>>()?;
780
781 Ok(old_to_new_exercise_ids)
782}
783
784async fn copy_exercise_slides(
785 tx: &mut PgConnection,
786 namespace_id: Uuid,
787 parent_id: Uuid,
788) -> ModelResult<()> {
789 sqlx::query!(
791 "
792 INSERT INTO exercise_slides (
793 id, exercise_id, order_number
794 )
795 SELECT uuid_generate_v5($1, id::text),
796 uuid_generate_v5($1, exercise_id::text),
797 order_number
798 FROM exercise_slides
799 WHERE exercise_id IN (SELECT id FROM exercises WHERE course_id = $2 OR exam_id = $2 AND deleted_at IS NULL)
800 AND deleted_at IS NULL;
801 ",
802 namespace_id,
803 parent_id
804 )
805 .execute(&mut *tx)
806 .await?;
807
808 Ok(())
809}
810
811async fn copy_exercise_tasks(
812 tx: &mut PgConnection,
813 namespace_id: Uuid,
814 parent_id: Uuid,
815) -> ModelResult<()> {
816 sqlx::query!(
818 "
819INSERT INTO exercise_tasks (
820 id,
821 exercise_slide_id,
822 exercise_type,
823 assignment,
824 private_spec,
825 public_spec,
826 model_solution_spec,
827 order_number,
828 copied_from
829 )
830SELECT uuid_generate_v5($1, id::text),
831 uuid_generate_v5($1, exercise_slide_id::text),
832 exercise_type,
833 assignment,
834 private_spec,
835 public_spec,
836 model_solution_spec,
837 order_number,
838 id
839FROM exercise_tasks
840WHERE exercise_slide_id IN (
841 SELECT s.id
842 FROM exercise_slides s
843 JOIN exercises e ON (e.id = s.exercise_id)
844 WHERE e.course_id = $2 OR e.exam_id = $2
845 AND e.deleted_at IS NULL
846 AND s.deleted_at IS NULL
847 )
848AND deleted_at IS NULL;
849 ",
850 namespace_id,
851 parent_id,
852 )
853 .execute(&mut *tx)
854 .await?;
855 Ok(())
856}
857
858pub async fn copy_user_permissions(
859 conn: &mut PgConnection,
860 new_course_id: Uuid,
861 old_course_id: Uuid,
862 user_id: Uuid,
863) -> ModelResult<()> {
864 sqlx::query!(
865 "
866INSERT INTO roles (
867 id,
868 user_id,
869 organization_id,
870 course_id,
871 role
872 )
873SELECT uuid_generate_v5($2, id::text),
874 user_id,
875 organization_id,
876 $2,
877 role
878FROM roles
879WHERE (course_id = $1)
880AND NOT (user_id = $3)
881AND deleted_at IS NULL;
882 ",
883 old_course_id,
884 new_course_id,
885 user_id
886 )
887 .execute(conn)
888 .await?;
889 Ok(())
890}
891
892async fn copy_peer_or_self_review_configs(
893 tx: &mut PgConnection,
894 namespace_id: Uuid,
895 parent_id: Uuid,
896) -> ModelResult<()> {
897 sqlx::query!(
898 "
899INSERT INTO peer_or_self_review_configs (
900 id,
901 course_id,
902 exercise_id,
903 peer_reviews_to_give,
904 peer_reviews_to_receive,
905 processing_strategy,
906 accepting_threshold,
907 manual_review_cutoff_in_days,
908 points_are_all_or_nothing,
909 review_instructions
910 )
911SELECT uuid_generate_v5($1, posrc.id::text),
912 $1,
913 uuid_generate_v5($1, posrc.exercise_id::text),
914 posrc.peer_reviews_to_give,
915 posrc.peer_reviews_to_receive,
916 posrc.processing_strategy,
917 posrc.accepting_threshold,
918 posrc.manual_review_cutoff_in_days,
919 posrc.points_are_all_or_nothing,
920 posrc.review_instructions
921FROM peer_or_self_review_configs posrc
922 LEFT JOIN exercises e ON (e.id = posrc.exercise_id)
923WHERE posrc.course_id = $2
924 AND posrc.deleted_at IS NULL
925 AND e.deleted_at IS NULL;
926 ",
927 namespace_id,
928 parent_id,
929 )
930 .execute(&mut *tx)
931 .await?;
932 Ok(())
933}
934
935async fn copy_peer_or_self_review_questions(
936 tx: &mut PgConnection,
937 namespace_id: Uuid,
938 parent_id: Uuid,
939) -> ModelResult<()> {
940 sqlx::query!(
941 "
942INSERT INTO peer_or_self_review_questions (
943 id,
944 peer_or_self_review_config_id,
945 order_number,
946 question,
947 question_type,
948 answer_required,
949 weight
950 )
951SELECT uuid_generate_v5($1, q.id::text),
952 uuid_generate_v5($1, q.peer_or_self_review_config_id::text),
953 q.order_number,
954 q.question,
955 q.question_type,
956 q.answer_required,
957 q.weight
958FROM peer_or_self_review_questions q
959 JOIN peer_or_self_review_configs posrc ON (posrc.id = q.peer_or_self_review_config_id)
960 JOIN exercises e ON (e.id = posrc.exercise_id)
961WHERE peer_or_self_review_config_id IN (
962 SELECT id
963 FROM peer_or_self_review_configs
964 WHERE course_id = $2
965 AND deleted_at IS NULL
966 )
967 AND q.deleted_at IS NULL
968 AND e.deleted_at IS NULL
969 AND posrc.deleted_at IS NULL;
970 ",
971 namespace_id,
972 parent_id,
973 )
974 .execute(&mut *tx)
975 .await?;
976 Ok(())
977}
978
979async fn copy_material_references(
980 tx: &mut PgConnection,
981 namespace_id: Uuid,
982 parent_id: Uuid,
983) -> ModelResult<()> {
984 sqlx::query!(
986 "
987INSERT INTO material_references (
988 citation_key,
989 course_id,
990 id,
991 reference
992)
993SELECT citation_key,
994 $1,
995 uuid_generate_v5($1, id::text),
996 reference
997FROM material_references
998WHERE course_id = $2
999AND deleted_at IS NULL;
1000 ",
1001 namespace_id,
1002 parent_id,
1003 )
1004 .execute(&mut *tx)
1005 .await?;
1006 Ok(())
1007}
1008
1009async fn copy_glossary_entries(
1010 tx: &mut PgConnection,
1011 new_course_id: Uuid,
1012 old_course_id: Uuid,
1013) -> ModelResult<()> {
1014 sqlx::query!(
1015 "
1016INSERT INTO glossary (
1017 id,
1018 course_id,
1019 term,
1020 definition
1021 )
1022SELECT uuid_generate_v5($1, id::text),
1023 $1,
1024 term,
1025 definition
1026FROM glossary
1027WHERE course_id = $2
1028 AND deleted_at IS NULL;
1029 ",
1030 new_course_id,
1031 old_course_id,
1032 )
1033 .execute(&mut *tx)
1034 .await?;
1035 Ok(())
1036}
1037
1038async fn copy_certificate_configurations_and_requirements(
1039 tx: &mut PgConnection,
1040 new_course_id: Uuid,
1041 old_course_id: Uuid,
1042) -> ModelResult<()> {
1043 sqlx::query!(
1044 "
1045INSERT INTO certificate_configurations (
1046 id,
1047 background_svg_file_upload_id,
1048 background_svg_path,
1049 certificate_date_font_size,
1050 certificate_date_text_anchor,
1051 certificate_date_text_color,
1052 certificate_date_x_pos,
1053 certificate_date_y_pos,
1054 certificate_grade_font_size,
1055 certificate_grade_text_anchor,
1056 certificate_grade_text_color,
1057 certificate_grade_x_pos,
1058 certificate_grade_y_pos,
1059 certificate_locale,
1060 certificate_owner_name_font_size,
1061 certificate_owner_name_text_anchor,
1062 certificate_owner_name_text_color,
1063 certificate_owner_name_x_pos,
1064 certificate_owner_name_y_pos,
1065 certificate_validate_url_font_size,
1066 certificate_validate_url_text_anchor,
1067 certificate_validate_url_text_color,
1068 certificate_validate_url_x_pos,
1069 certificate_validate_url_y_pos,
1070 overlay_svg_file_upload_id,
1071 overlay_svg_path,
1072 paper_size,
1073 render_certificate_grade
1074 )
1075SELECT uuid_generate_v5($1, id::text),
1076 background_svg_file_upload_id,
1077 background_svg_path,
1078 certificate_date_font_size,
1079 certificate_date_text_anchor,
1080 certificate_date_text_color,
1081 certificate_date_x_pos,
1082 certificate_date_y_pos,
1083 certificate_grade_font_size,
1084 certificate_grade_text_anchor,
1085 certificate_grade_text_color,
1086 certificate_grade_x_pos,
1087 certificate_grade_y_pos,
1088 certificate_locale,
1089 certificate_owner_name_font_size,
1090 certificate_owner_name_text_anchor,
1091 certificate_owner_name_text_color,
1092 certificate_owner_name_x_pos,
1093 certificate_owner_name_y_pos,
1094 certificate_validate_url_font_size,
1095 certificate_validate_url_text_anchor,
1096 certificate_validate_url_text_color,
1097 certificate_validate_url_x_pos,
1098 certificate_validate_url_y_pos,
1099 overlay_svg_file_upload_id,
1100 overlay_svg_path,
1101 paper_size,
1102 render_certificate_grade
1103FROM certificate_configurations
1104WHERE id IN (
1105 SELECT certificate_configuration_id
1106 FROM certificate_configuration_to_requirements cctr
1107 JOIN course_modules cm ON cctr.course_module_id = cm.id
1108 WHERE cm.course_id = $2
1109 AND cctr.deleted_at IS NULL
1110 AND cm.deleted_at IS NULL
1111 )
1112 AND deleted_at IS NULL;
1113 ",
1114 new_course_id,
1115 old_course_id
1116 )
1117 .execute(&mut *tx)
1118 .await?;
1119
1120 sqlx::query!(
1121 "
1122INSERT INTO certificate_configuration_to_requirements (
1123 id,
1124 certificate_configuration_id,
1125 course_module_id
1126 )
1127SELECT uuid_generate_v5($1, cctr.id::text),
1128 uuid_generate_v5($1, cctr.certificate_configuration_id::text),
1129 uuid_generate_v5($1, cctr.course_module_id::text)
1130FROM certificate_configuration_to_requirements cctr
1131 JOIN course_modules cm ON cctr.course_module_id = cm.id
1132WHERE cm.course_id = $2
1133 AND cctr.deleted_at IS NULL
1134 AND cm.deleted_at IS NULL;
1135 ",
1136 new_course_id,
1137 old_course_id
1138 )
1139 .execute(&mut *tx)
1140 .await?;
1141
1142 Ok(())
1143}
1144
1145async fn copy_chatbot_configurations(
1146 tx: &mut PgConnection,
1147 new_course_id: Uuid,
1148 old_course_id: Uuid,
1149) -> ModelResult<()> {
1150 sqlx::query!(
1151 "
1152INSERT INTO chatbot_configurations (
1153 id,
1154 course_id,
1155 chatbot_name,
1156 initial_message,
1157 prompt,
1158 use_azure_search,
1159 maintain_azure_search_index,
1160 use_semantic_reranking,
1161 hide_citations,
1162 temperature,
1163 top_p,
1164 presence_penalty,
1165 frequency_penalty,
1166 response_max_tokens,
1167 daily_tokens_per_user,
1168 weekly_tokens_per_user,
1169 default_chatbot,
1170 enabled_to_students,
1171 model_id,
1172 thinking_model,
1173 use_tools
1174 )
1175SELECT
1176 uuid_generate_v5($1, id::text),
1177 $1,
1178 chatbot_name,
1179 initial_message,
1180 prompt,
1181 use_azure_search,
1182 maintain_azure_search_index,
1183 use_semantic_reranking,
1184 hide_citations,
1185 temperature,
1186 top_p,
1187 presence_penalty,
1188 frequency_penalty,
1189 response_max_tokens,
1190 daily_tokens_per_user,
1191 weekly_tokens_per_user,
1192 default_chatbot,
1193 enabled_to_students,
1194 model_id,
1195 thinking_model,
1196 use_tools
1197FROM chatbot_configurations
1198WHERE course_id = $2
1199 AND deleted_at IS NULL;
1200 ",
1201 new_course_id,
1202 old_course_id
1203 )
1204 .execute(&mut *tx)
1205 .await?;
1206 Ok(())
1207}
1208
1209async fn copy_cheater_thresholds(
1210 tx: &mut PgConnection,
1211 new_course_id: Uuid,
1212 old_course_id: Uuid,
1213) -> ModelResult<()> {
1214 let old_default_module =
1215 crate::course_modules::get_default_by_course_id(tx, old_course_id).await?;
1216 let new_default_module =
1217 crate::course_modules::get_default_by_course_id(tx, new_course_id).await?;
1218
1219 sqlx::query!(
1220 "
1221INSERT INTO cheater_thresholds (id, course_module_id, duration_seconds)
1222SELECT
1223 uuid_generate_v5($1, id::text),
1224 $2,
1225 duration_seconds
1226FROM cheater_thresholds
1227WHERE course_module_id = $3
1228 AND deleted_at IS NULL;
1229 ",
1230 new_course_id,
1231 new_default_module.id,
1232 old_default_module.id
1233 )
1234 .execute(&mut *tx)
1235 .await?;
1236 Ok(())
1237}
1238
1239async fn copy_course_custom_privacy_policy_checkbox_texts(
1240 tx: &mut PgConnection,
1241 new_course_id: Uuid,
1242 old_course_id: Uuid,
1243) -> ModelResult<()> {
1244 sqlx::query!(
1245 "
1246INSERT INTO course_custom_privacy_policy_checkbox_texts (id, course_id, text_slug, text_html)
1247SELECT uuid_generate_v5($1, id::text),
1248 $1,
1249 text_slug,
1250 text_html
1251FROM course_custom_privacy_policy_checkbox_texts
1252WHERE course_id = $2
1253 AND deleted_at IS NULL;
1254 ",
1255 new_course_id,
1256 old_course_id
1257 )
1258 .execute(&mut *tx)
1259 .await?;
1260 Ok(())
1261}
1262
1263async fn copy_exercise_repositories(
1264 tx: &mut PgConnection,
1265 new_course_id: Uuid,
1266 old_course_id: Uuid,
1267) -> ModelResult<()> {
1268 sqlx::query!(
1269 "
1270INSERT INTO exercise_repositories (
1271 id,
1272 course_id,
1273 url,
1274 deploy_key,
1275 public_key,
1276 STATUS,
1277 error_message
1278 )
1279SELECT uuid_generate_v5($1, id::text),
1280 $1,
1281 url,
1282 deploy_key,
1283 public_key,
1284 STATUS,
1285 error_message
1286FROM exercise_repositories
1287WHERE course_id = $2
1288 AND deleted_at IS NULL;
1289 ",
1290 new_course_id,
1291 old_course_id
1292 )
1293 .execute(&mut *tx)
1294 .await?;
1295 Ok(())
1296}
1297
1298async fn copy_partners_blocks(
1299 tx: &mut PgConnection,
1300 new_course_id: Uuid,
1301 old_course_id: Uuid,
1302) -> ModelResult<()> {
1303 sqlx::query!(
1304 "
1305INSERT INTO partners_blocks (id, course_id, content)
1306SELECT uuid_generate_v5($1, id::text),
1307 $1,
1308 content
1309FROM partners_blocks
1310WHERE course_id = $2
1311 AND deleted_at IS NULL;
1312 ",
1313 new_course_id,
1314 old_course_id
1315 )
1316 .execute(&mut *tx)
1317 .await?;
1318 Ok(())
1319}
1320
1321async fn copy_privacy_links(
1322 tx: &mut PgConnection,
1323 new_course_id: Uuid,
1324 old_course_id: Uuid,
1325) -> ModelResult<()> {
1326 sqlx::query!(
1327 "
1328INSERT INTO privacy_links (id, course_id, url, title)
1329SELECT uuid_generate_v5($1, id::text),
1330 $1,
1331 url,
1332 title
1333FROM privacy_links
1334WHERE course_id = $2
1335 AND deleted_at IS NULL;
1336 ",
1337 new_course_id,
1338 old_course_id
1339 )
1340 .execute(&mut *tx)
1341 .await?;
1342 Ok(())
1343}
1344
1345async fn copy_research_consent_forms_and_questions(
1346 tx: &mut PgConnection,
1347 new_course_id: Uuid,
1348 old_course_id: Uuid,
1349) -> ModelResult<()> {
1350 sqlx::query!(
1351 "
1352INSERT INTO course_specific_research_consent_forms (id, course_id, content)
1353SELECT uuid_generate_v5($1, id::text),
1354 $1,
1355 content
1356FROM course_specific_research_consent_forms
1357WHERE course_id = $2
1358 AND deleted_at IS NULL;
1359 ",
1360 new_course_id,
1361 old_course_id
1362 )
1363 .execute(&mut *tx)
1364 .await?;
1365
1366 sqlx::query!(
1367 "
1368INSERT INTO course_specific_consent_form_questions (
1369 id,
1370 course_id,
1371 research_consent_form_id,
1372 question
1373 )
1374SELECT uuid_generate_v5($1, id::text),
1375 $1,
1376 uuid_generate_v5($1, research_consent_form_id::text),
1377 question
1378FROM course_specific_consent_form_questions
1379WHERE course_id = $2
1380 AND deleted_at IS NULL;
1381 ",
1382 new_course_id,
1383 old_course_id
1384 )
1385 .execute(&mut *tx)
1386 .await?;
1387
1388 Ok(())
1389}
1390
1391#[cfg(test)]
1392mod tests {
1393 use super::*;
1394 use crate::{exercise_tasks::ExerciseTask, pages::Page, test_helper::*};
1395 use pretty_assertions::assert_eq;
1396
1397 #[tokio::test]
1398 async fn elg_preserved_when_same_course_language_group() {
1399 insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
1400 :chapter, :page, :exercise);
1401 let original_ex = crate::exercises::get_by_id(tx.as_mut(), exercise)
1402 .await
1403 .unwrap();
1404
1405 let new_meta = create_new_course(org, "fi-FI".into());
1407 let copied_course = copy_course(tx.as_mut(), course, &new_meta, true, user)
1408 .await
1409 .unwrap();
1410
1411 let copied_ex = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
1412 .await
1413 .unwrap()
1414 .pop()
1415 .unwrap();
1416
1417 assert_eq!(
1418 original_ex.exercise_language_group_id, copied_ex.exercise_language_group_id,
1419 "ELG must stay identical when CLG is unchanged"
1420 );
1421 }
1422
1423 #[tokio::test]
1426 async fn elg_deterministic_when_reusing_target_clg() {
1427 insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
1428 :chapter, :page, exercise: _e);
1429
1430 let reusable_clg =
1432 course_language_groups::insert(tx.as_mut(), PKeyPolicy::Generate, "reusable-clg")
1433 .await
1434 .unwrap();
1435
1436 let meta1 = create_new_course(org, "en-US".into());
1437 let copy1 =
1438 copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta1, user)
1439 .await
1440 .unwrap();
1441
1442 let meta2 = {
1443 let mut nc = create_new_course(org, "pt-BR".into());
1444 nc.slug = "copied-course-2".into(); nc
1446 };
1447 let copy2 =
1448 copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta2, user)
1449 .await
1450 .unwrap();
1451
1452 let ex1 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy1.id)
1453 .await
1454 .unwrap()
1455 .pop()
1456 .unwrap();
1457 let ex2 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy2.id)
1458 .await
1459 .unwrap()
1460 .pop()
1461 .unwrap();
1462
1463 assert_ne!(ex1.course_id, ex2.course_id); assert_eq!(
1465 ex1.exercise_language_group_id, ex2.exercise_language_group_id,
1466 "ELG must be deterministic for the same (target CLG, src exercise)"
1467 );
1468 }
1469
1470 #[tokio::test]
1471 async fn copies_course_as_different_course_language_group() {
1472 insert_data!(:tx, :user, :org, :course);
1473 let course = crate::courses::get_course(tx.as_mut(), course)
1474 .await
1475 .unwrap();
1476 let new_course = create_new_course(org, "en-US".into());
1477 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, false, user)
1478 .await
1479 .unwrap();
1480 assert_ne!(
1481 course.course_language_group_id,
1482 copied_course.course_language_group_id
1483 );
1484 }
1485
1486 #[tokio::test]
1487 async fn copies_course_as_same_course_language_group() {
1488 insert_data!(:tx, :user, :org, :course);
1489 let course = crate::courses::get_course(tx.as_mut(), course)
1490 .await
1491 .unwrap();
1492 let new_course = create_new_course(org, "fi-FI".into());
1493 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1494 .await
1495 .unwrap();
1496 assert_eq!(
1497 course.course_language_group_id,
1498 copied_course.course_language_group_id
1499 );
1500 }
1501
1502 #[tokio::test]
1503 async fn copies_course_instances() {
1504 insert_data!(:tx, :user, :org, :course, instance: _instance);
1505 let course = crate::courses::get_course(tx.as_mut(), course)
1506 .await
1507 .unwrap();
1508 let new_course = create_new_course(org, "en-GB".into());
1509 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1510 .await
1511 .unwrap();
1512 let copied_instances =
1513 crate::course_instances::get_course_instances_for_course(tx.as_mut(), copied_course.id)
1514 .await
1515 .unwrap();
1516 assert_eq!(copied_instances.len(), 1);
1517 }
1518
1519 #[tokio::test]
1520 async fn copies_course_modules() {
1521 insert_data!(:tx, :user, :org, :course);
1522 let course = crate::courses::get_course(tx.as_mut(), course)
1523 .await
1524 .unwrap();
1525 let new_course = create_new_course(org, "pt-BR".into());
1526 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1527 .await
1528 .unwrap();
1529
1530 let original_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course.id)
1531 .await
1532 .unwrap();
1533 let copied_modules = crate::course_modules::get_by_course_id(tx.as_mut(), copied_course.id)
1534 .await
1535 .unwrap();
1536 assert_eq!(
1537 original_modules.first().unwrap().id,
1538 copied_modules.first().unwrap().copied_from.unwrap(),
1539 )
1540 }
1541
1542 #[tokio::test]
1543 async fn copies_course_chapters() {
1544 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter);
1545 let course = crate::courses::get_course(tx.as_mut(), course)
1546 .await
1547 .unwrap();
1548 let new_course = create_new_course(org, "sv-SV".into());
1549 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1550 .await
1551 .unwrap();
1552 let copied_chapters = crate::chapters::course_chapters(tx.as_mut(), copied_course.id)
1553 .await
1554 .unwrap();
1555 assert_eq!(copied_chapters.len(), 1);
1556 assert_eq!(copied_chapters.first().unwrap().copied_from, Some(chapter));
1557 }
1558
1559 #[tokio::test]
1560 async fn updates_chapter_front_pages() {
1561 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, chapter: _chapter);
1562 let course = crate::courses::get_course(tx.as_mut(), course)
1563 .await
1564 .unwrap();
1565 let new_course = create_new_course(org, "fr-CA".into());
1566 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1567 .await
1568 .unwrap();
1569 let copied_chapters = crate::chapters::course_chapters(tx.as_mut(), copied_course.id)
1570 .await
1571 .unwrap();
1572 let copied_chapter = copied_chapters.first().unwrap();
1573 let copied_chapter_front_page =
1574 crate::pages::get_page(tx.as_mut(), copied_chapter.front_page_id.unwrap())
1575 .await
1576 .unwrap();
1577 assert_eq!(copied_chapter_front_page.course_id, Some(copied_course.id));
1578 }
1579
1580 #[tokio::test]
1581 async fn copies_course_pages() {
1582 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, page: _page);
1583 let course = crate::courses::get_course(tx.as_mut(), course)
1584 .await
1585 .unwrap();
1586 let new_course = create_new_course(org, "es-US".into());
1587 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1588 .await
1589 .unwrap();
1590 let mut original_pages_by_id: HashMap<Uuid, Page> =
1591 crate::pages::get_all_by_course_id_and_visibility(
1592 tx.as_mut(),
1593 course.id,
1594 crate::pages::PageVisibility::Any,
1595 )
1596 .await
1597 .unwrap()
1598 .into_iter()
1599 .map(|page| (page.id, page))
1600 .collect();
1601 assert_eq!(original_pages_by_id.len(), 3);
1602 let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1603 tx.as_mut(),
1604 copied_course.id,
1605 crate::pages::PageVisibility::Any,
1606 )
1607 .await
1608 .unwrap();
1609 assert_eq!(copied_pages.len(), 3);
1610 copied_pages.into_iter().for_each(|copied_page| {
1611 assert!(
1612 original_pages_by_id
1613 .remove(&copied_page.copied_from.unwrap())
1614 .is_some()
1615 );
1616 });
1617 assert!(original_pages_by_id.is_empty());
1618 }
1619
1620 #[tokio::test]
1621 async fn updates_course_slugs_in_internal_links_in_pages_contents() {
1622 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page);
1623 let course = crate::courses::get_course(tx.as_mut(), course)
1624 .await
1625 .unwrap();
1626 crate::pages::update_page_content(
1627 tx.as_mut(),
1628 page,
1629 &serde_json::json!([{
1630 "name": "core/paragraph",
1631 "isValid": true,
1632 "clientId": "b2ecb473-38cc-4df1-84f7-45709cc63e95",
1633 "attributes": {
1634 "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),
1635 "dropCap":false
1636 },
1637 "innerBlocks": []
1638 }]),
1639 )
1640 .await.unwrap();
1641
1642 let new_course = create_new_course(org, "fi-FI".into());
1643 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1644 .await
1645 .unwrap();
1646
1647 let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1648 tx.as_mut(),
1649 copied_course.id,
1650 crate::pages::PageVisibility::Any,
1651 )
1652 .await
1653 .unwrap();
1654 let copied_page = copied_pages
1655 .into_iter()
1656 .find(|copied_page| copied_page.copied_from == Some(page))
1657 .unwrap();
1658 let copied_content_in_page = copied_page.content[0]["attributes"]["content"]
1659 .as_str()
1660 .unwrap();
1661 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>";
1662 assert_eq!(copied_content_in_page, content_with_updated_course_slug);
1663 }
1664
1665 #[tokio::test]
1666 async fn updates_exercise_id_in_content() {
1667 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise);
1668 let course = crate::courses::get_course(tx.as_mut(), course)
1669 .await
1670 .unwrap();
1671 crate::pages::update_page_content(
1672 tx.as_mut(),
1673 page,
1674 &serde_json::json!([{
1675 "name": "moocfi/exercise",
1676 "isValid": true,
1677 "clientId": "b2ecb473-38cc-4df1-84f7-06709cc63e95",
1678 "attributes": {
1679 "id": exercise,
1680 "name": "Exercise"
1681 },
1682 "innerBlocks": []
1683 }]),
1684 )
1685 .await
1686 .unwrap();
1687 let new_course = create_new_course(org, "es-MX".into());
1688 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1689 .await
1690 .unwrap();
1691 let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1692 tx.as_mut(),
1693 copied_course.id,
1694 crate::pages::PageVisibility::Any,
1695 )
1696 .await
1697 .unwrap();
1698 let copied_page = copied_pages
1699 .into_iter()
1700 .find(|copied_page| copied_page.copied_from == Some(page))
1701 .unwrap();
1702 let copied_exercise_id_in_content =
1703 Uuid::parse_str(copied_page.content[0]["attributes"]["id"].as_str().unwrap()).unwrap();
1704 let copied_exercise =
1705 crate::exercises::get_by_id(tx.as_mut(), copied_exercise_id_in_content)
1706 .await
1707 .unwrap();
1708 assert_eq!(copied_exercise.course_id.unwrap(), copied_course.id);
1709 }
1710
1711 #[tokio::test]
1712 async fn copies_exercises_tasks_and_slides() {
1713 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise, :slide, :task);
1714 let course = crate::courses::get_course(tx.as_mut(), course)
1715 .await
1716 .unwrap();
1717 let new_course = create_new_course(org, "fi-SV".into());
1718 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1719 .await
1720 .unwrap();
1721 let copied_exercises =
1722 crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
1723 .await
1724 .unwrap();
1725 assert_eq!(copied_exercises.len(), 1);
1726 let copied_exercise = copied_exercises.first().unwrap();
1727 assert_eq!(copied_exercise.copied_from, Some(exercise));
1728 let original_exercise = crate::exercises::get_by_id(tx.as_mut(), exercise)
1729 .await
1730 .unwrap();
1731 assert_eq!(
1732 copied_exercise.max_tries_per_slide,
1733 original_exercise.max_tries_per_slide
1734 );
1735 assert_eq!(
1736 copied_exercise.limit_number_of_tries,
1737 original_exercise.limit_number_of_tries
1738 );
1739 assert_eq!(
1740 copied_exercise.needs_peer_review,
1741 original_exercise.needs_peer_review
1742 );
1743 assert_eq!(
1744 copied_exercise.use_course_default_peer_or_self_review_config,
1745 original_exercise.use_course_default_peer_or_self_review_config
1746 );
1747 let copied_slides = crate::exercise_slides::get_exercise_slides_by_exercise_id(
1748 tx.as_mut(),
1749 copied_exercise.id,
1750 )
1751 .await
1752 .unwrap();
1753 assert_eq!(copied_slides.len(), 1);
1754 let copied_slide = copied_slides.first().unwrap();
1755 let copied_tasks: Vec<ExerciseTask> =
1756 crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_id(
1757 tx.as_mut(),
1758 &copied_slide.id,
1759 )
1760 .await
1761 .unwrap();
1762 assert_eq!(copied_tasks.len(), 1);
1763 let copied_task = copied_tasks.first().unwrap();
1764 assert_eq!(copied_task.copied_from, Some(task));
1765
1766 let original_course_chapters = crate::chapters::course_chapters(tx.as_mut(), course.id)
1767 .await
1768 .unwrap();
1769 for original_chapter in original_course_chapters {
1770 for copied_exercise in &copied_exercises {
1771 assert_ne!(original_chapter.id, copied_exercise.id);
1772 }
1773 }
1774 }
1775
1776 fn create_new_course(organization_id: Uuid, language_code: String) -> NewCourse {
1777 NewCourse {
1778 name: "Copied course".to_string(),
1779 slug: "copied-course".to_string(),
1780 organization_id,
1781 language_code,
1782 teacher_in_charge_name: "Teacher".to_string(),
1783 teacher_in_charge_email: "teacher@example.com".to_string(),
1784 description: "".to_string(),
1785 is_draft: true,
1786 is_test_mode: false,
1787 is_unlisted: false,
1788 copy_user_permissions: false,
1789 is_joinable_by_code_only: false,
1790 join_code: None,
1791 ask_marketing_consent: false,
1792 flagged_answers_threshold: Some(3),
1793 can_add_chatbot: false,
1794 }
1795 }
1796}