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