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