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