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