1use std::collections::HashMap;
2
3use serde_json::Value;
4
5use crate::ModelResult;
6use crate::course_instances;
7use crate::course_instances::NewCourseInstance;
8use crate::course_language_groups;
9use crate::courses::Course;
10use crate::courses::CourseAiPolicy;
11use crate::courses::NewCourse;
12use crate::courses::get_course;
13use crate::exams;
14use crate::exams::Exam;
15use crate::exams::NewExam;
16use crate::pages;
17use crate::prelude::*;
18
19pub async fn copy_course(
20 conn: &mut PgConnection,
21 course_id: Uuid,
22 new_course: &NewCourse,
23 same_language_group: bool,
24 user_id: Uuid,
25) -> ModelResult<Course> {
26 let mut tx = conn.begin().await?;
27 let parent_course = get_course(&mut tx, course_id).await?;
28 let course_language_group_id = if same_language_group {
29 parent_course.course_language_group_id
30 } else {
31 course_language_groups::insert(&mut tx, PKeyPolicy::Generate, &new_course.slug).await?
32 };
33
34 let copied_course = copy_course_with_language_group(
35 &mut tx,
36 course_id,
37 course_language_group_id,
38 new_course,
39 user_id,
40 )
41 .await?;
42
43 tx.commit().await?;
44
45 Ok(copied_course)
46}
47
48pub async fn copy_course_with_language_group(
49 conn: &mut PgConnection,
50 src_course_id: Uuid,
51 target_clg_id: Uuid,
52 new_course: &NewCourse,
53 user_id: Uuid,
54) -> ModelResult<Course> {
55 let parent_course = get_course(conn, src_course_id).await?;
56 let same_clg = target_clg_id == parent_course.course_language_group_id;
57
58 let mut tx = conn.begin().await?;
59
60 let copied_course = sqlx::query_as!(
61 Course,
62 r#"
63INSERT INTO courses (
64 name,
65 organization_id,
66 slug,
67 content_search_language,
68 language_code,
69 copied_from,
70 course_language_group_id,
71 is_draft,
72 base_module_completion_requires_n_submodule_completions,
73 can_add_chatbot,
74 is_unlisted,
75 is_joinable_by_code_only,
76 join_code,
77 ask_marketing_consent,
78 description,
79 flagged_answers_threshold,
80 flagged_answers_skip_manual_review_and_allow_retry,
81 cheater_detection_enabled,
82 chapter_locking_enabled,
83 ai_policy,
84 course_material_ai_instructions
85 )
86VALUES (
87 $1,
88 $2,
89 $3,
90 $4,
91 $5,
92 $6,
93 $7,
94 $8,
95 $9,
96 $10,
97 $11,
98 $12,
99 $13,
100 $14,
101 $15,
102 $16,
103 $17,
104 $18,
105 $19,
106 $20,
107 $21
108 )
109RETURNING id,
110 name,
111 created_at,
112 updated_at,
113 organization_id,
114 deleted_at,
115 slug,
116 content_search_language::text,
117 language_code,
118 copied_from,
119 course_language_group_id,
120 description,
121 is_draft,
122 is_test_mode,
123 base_module_completion_requires_n_submodule_completions,
124 can_add_chatbot,
125 is_unlisted,
126 is_joinable_by_code_only,
127 join_code,
128 ask_marketing_consent,
129 flagged_answers_threshold,
130 flagged_answers_skip_manual_review_and_allow_retry,
131 closed_at,
132 closed_additional_message,
133 closed_course_successor_id,
134 chapter_locking_enabled,
135 cheater_detection_enabled,
136 ai_policy,
137 course_material_ai_instructions
138 "#,
139 new_course.name,
140 new_course.organization_id,
141 new_course.slug,
142 parent_course.content_search_language as _,
143 new_course.language_code,
144 parent_course.id,
145 target_clg_id,
146 new_course.is_draft,
147 parent_course.base_module_completion_requires_n_submodule_completions,
148 parent_course.can_add_chatbot,
149 new_course.is_unlisted,
150 new_course.is_joinable_by_code_only,
151 new_course.join_code,
152 new_course.ask_marketing_consent,
153 parent_course.description,
154 parent_course.flagged_answers_threshold,
155 parent_course.flagged_answers_skip_manual_review_and_allow_retry,
156 parent_course.cheater_detection_enabled,
157 parent_course.chapter_locking_enabled,
158 parent_course.ai_policy as CourseAiPolicy,
159 parent_course.course_material_ai_instructions
160 )
161 .fetch_one(&mut *tx)
162 .await?;
163
164 copy_course_modules(&mut tx, copied_course.id, src_course_id).await?;
165 copy_course_chapters(&mut tx, copied_course.id, src_course_id).await?;
166
167 if new_course.copy_user_permissions {
168 copy_user_permissions(&mut tx, copied_course.id, src_course_id, user_id).await?;
169 }
170
171 let contents_iter =
172 copy_course_pages_and_return_contents(&mut tx, copied_course.id, src_course_id).await?;
173
174 set_chapter_front_pages(&mut tx, copied_course.id).await?;
175
176 let old_to_new_exercise_ids = map_old_exr_ids_to_new_exr_ids_for_courses(
177 &mut tx,
178 copied_course.id,
179 src_course_id,
180 target_clg_id,
181 same_clg,
182 )
183 .await?;
184
185 for (page_id, content) in contents_iter {
187 if let Value::Array(mut blocks) = content {
188 for block in blocks.iter_mut() {
189 if block["name"] != Value::String("moocfi/exercise".to_string()) {
190 continue;
191 }
192 if let Value::String(old_id) = &block["attributes"]["id"] {
193 let new_id = old_to_new_exercise_ids
194 .get(old_id)
195 .ok_or_else(|| {
196 ModelError::new(
197 ModelErrorType::Generic,
198 "Invalid exercise id in content.".to_string(),
199 None,
200 )
201 })?
202 .to_string();
203 block["attributes"]["id"] = Value::String(new_id);
204 }
205 }
206 sqlx::query!(
207 r#"
208UPDATE pages
209SET content = $1
210WHERE id = $2;
211"#,
212 Value::Array(blocks),
213 page_id
214 )
215 .execute(&mut *tx)
216 .await?;
217 }
218 }
219
220 let pages_contents = pages::get_all_by_course_id_and_visibility(
221 tx.as_mut(),
222 copied_course.id,
223 pages::PageVisibility::Any,
224 )
225 .await?
226 .into_iter()
227 .map(|page| (page.id, page.content))
228 .collect::<HashMap<_, _>>();
229
230 for (page_id, content) in pages_contents {
231 if let Value::Array(mut blocks) = content {
232 for block in blocks.iter_mut() {
233 if let Some(content) = block["attributes"]["content"].as_str()
234 && content.contains("<a href=")
235 {
236 block["attributes"]["content"] =
237 Value::String(content.replace(&parent_course.slug, &new_course.slug));
238 }
239 }
240 sqlx::query!(
241 r#"
242UPDATE pages
243SET content = $1
244WHERE id = $2;
245"#,
246 Value::Array(blocks),
247 page_id
248 )
249 .execute(&mut *tx)
250 .await?;
251 }
252 }
253
254 copy_exercise_slides(&mut tx, copied_course.id, src_course_id).await?;
255 copy_exercise_tasks(&mut tx, copied_course.id, src_course_id).await?;
256
257 course_instances::insert(
259 &mut tx,
260 PKeyPolicy::Generate,
261 NewCourseInstance {
262 course_id: copied_course.id,
263 name: None,
264 description: None,
265 support_email: None,
266 teacher_in_charge_name: &new_course.teacher_in_charge_name,
267 teacher_in_charge_email: &new_course.teacher_in_charge_email,
268 opening_time: None,
269 closing_time: None,
270 },
271 )
272 .await?;
273
274 copy_peer_or_self_review_configs(&mut tx, copied_course.id, src_course_id).await?;
275 copy_peer_or_self_review_questions(&mut tx, copied_course.id, src_course_id).await?;
276 copy_material_references(&mut tx, copied_course.id, src_course_id).await?;
277 copy_glossary_entries(&mut tx, copied_course.id, src_course_id).await?;
278
279 copy_certificate_configurations_and_requirements(&mut tx, copied_course.id, src_course_id)
281 .await?;
282 copy_chatbot_configurations(&mut tx, copied_course.id, src_course_id).await?;
283 copy_cheater_thresholds(&mut tx, copied_course.id, src_course_id).await?;
284 copy_course_module_suotar_configurations(&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
871 sqlx::query!(
875 "
876INSERT INTO exercise_task_spec_files (exercise_task_id, file_upload_id, spec_kind)
877SELECT uuid_generate_v5($1, f.exercise_task_id::text),
878 f.file_upload_id,
879 f.spec_kind
880FROM exercise_task_spec_files f
881 JOIN exercise_tasks t ON t.id = f.exercise_task_id
882 JOIN exercise_slides s ON s.id = t.exercise_slide_id
883 JOIN exercises e ON e.id = s.exercise_id
884WHERE (e.course_id = $2 OR e.exam_id = $2)
885 AND e.deleted_at IS NULL
886 AND s.deleted_at IS NULL
887 AND t.deleted_at IS NULL
888 AND f.deleted_at IS NULL;
889 ",
890 namespace_id,
891 parent_id,
892 )
893 .execute(&mut *tx)
894 .await?;
895 Ok(())
896}
897
898pub async fn copy_user_permissions(
899 conn: &mut PgConnection,
900 new_course_id: Uuid,
901 old_course_id: Uuid,
902 user_id: Uuid,
903) -> ModelResult<()> {
904 sqlx::query!(
905 "
906INSERT INTO roles (
907 id,
908 user_id,
909 organization_id,
910 course_id,
911 role
912 )
913SELECT uuid_generate_v5($2, id::text),
914 user_id,
915 organization_id,
916 $2,
917 role
918FROM roles
919WHERE (course_id = $1)
920AND NOT (user_id = $3)
921AND deleted_at IS NULL;
922 ",
923 old_course_id,
924 new_course_id,
925 user_id
926 )
927 .execute(conn)
928 .await?;
929 Ok(())
930}
931
932async fn copy_peer_or_self_review_configs(
933 tx: &mut PgConnection,
934 namespace_id: Uuid,
935 parent_id: Uuid,
936) -> ModelResult<()> {
937 sqlx::query!(
938 "
939INSERT INTO peer_or_self_review_configs (
940 id,
941 course_id,
942 exercise_id,
943 peer_reviews_to_give,
944 peer_reviews_to_receive,
945 processing_strategy,
946 accepting_threshold,
947 manual_review_cutoff_in_days,
948 points_are_all_or_nothing,
949 review_instructions
950 )
951SELECT uuid_generate_v5($1, posrc.id::text),
952 $1,
953 uuid_generate_v5($1, posrc.exercise_id::text),
954 posrc.peer_reviews_to_give,
955 posrc.peer_reviews_to_receive,
956 posrc.processing_strategy,
957 posrc.accepting_threshold,
958 posrc.manual_review_cutoff_in_days,
959 posrc.points_are_all_or_nothing,
960 posrc.review_instructions
961FROM peer_or_self_review_configs posrc
962 LEFT JOIN exercises e ON (e.id = posrc.exercise_id)
963WHERE posrc.course_id = $2
964 AND posrc.deleted_at IS NULL
965 AND e.deleted_at IS NULL;
966 ",
967 namespace_id,
968 parent_id,
969 )
970 .execute(&mut *tx)
971 .await?;
972 Ok(())
973}
974
975async fn copy_peer_or_self_review_questions(
976 tx: &mut PgConnection,
977 namespace_id: Uuid,
978 parent_id: Uuid,
979) -> ModelResult<()> {
980 sqlx::query!(
981 "
982INSERT INTO peer_or_self_review_questions (
983 id,
984 peer_or_self_review_config_id,
985 order_number,
986 question,
987 question_type,
988 answer_required,
989 weight
990 )
991SELECT uuid_generate_v5($1, q.id::text),
992 uuid_generate_v5($1, q.peer_or_self_review_config_id::text),
993 q.order_number,
994 q.question,
995 q.question_type,
996 q.answer_required,
997 q.weight
998FROM peer_or_self_review_questions q
999 JOIN peer_or_self_review_configs posrc ON (posrc.id = q.peer_or_self_review_config_id)
1000 LEFT JOIN exercises e ON (e.id = posrc.exercise_id)
1001WHERE peer_or_self_review_config_id IN (
1002 SELECT id
1003 FROM peer_or_self_review_configs
1004 WHERE course_id = $2
1005 AND deleted_at IS NULL
1006 )
1007 AND q.deleted_at IS NULL
1008 AND e.deleted_at IS NULL
1009 AND posrc.deleted_at IS NULL;
1010 ",
1011 namespace_id,
1012 parent_id,
1013 )
1014 .execute(&mut *tx)
1015 .await?;
1016 Ok(())
1017}
1018
1019async fn copy_material_references(
1020 tx: &mut PgConnection,
1021 namespace_id: Uuid,
1022 parent_id: Uuid,
1023) -> ModelResult<()> {
1024 sqlx::query!(
1026 "
1027INSERT INTO material_references (
1028 citation_key,
1029 course_id,
1030 id,
1031 reference
1032)
1033SELECT citation_key,
1034 $1,
1035 uuid_generate_v5($1, id::text),
1036 reference
1037FROM material_references
1038WHERE course_id = $2
1039AND deleted_at IS NULL;
1040 ",
1041 namespace_id,
1042 parent_id,
1043 )
1044 .execute(&mut *tx)
1045 .await?;
1046 Ok(())
1047}
1048
1049async fn copy_glossary_entries(
1050 tx: &mut PgConnection,
1051 new_course_id: Uuid,
1052 old_course_id: Uuid,
1053) -> ModelResult<()> {
1054 sqlx::query!(
1055 "
1056INSERT INTO glossary (
1057 id,
1058 course_id,
1059 term,
1060 definition
1061 )
1062SELECT uuid_generate_v5($1, id::text),
1063 $1,
1064 term,
1065 definition
1066FROM glossary
1067WHERE course_id = $2
1068 AND deleted_at IS NULL;
1069 ",
1070 new_course_id,
1071 old_course_id,
1072 )
1073 .execute(&mut *tx)
1074 .await?;
1075 Ok(())
1076}
1077
1078async fn copy_certificate_configurations_and_requirements(
1079 tx: &mut PgConnection,
1080 new_course_id: Uuid,
1081 old_course_id: Uuid,
1082) -> ModelResult<()> {
1083 sqlx::query!(
1084 "
1085INSERT INTO certificate_configurations (
1086 id,
1087 background_svg_file_upload_id,
1088 background_svg_path,
1089 certificate_date_font_size,
1090 certificate_date_text_anchor,
1091 certificate_date_text_color,
1092 certificate_date_x_pos,
1093 certificate_date_y_pos,
1094 certificate_grade_font_size,
1095 certificate_grade_text_anchor,
1096 certificate_grade_text_color,
1097 certificate_grade_x_pos,
1098 certificate_grade_y_pos,
1099 certificate_locale,
1100 certificate_owner_name_font_size,
1101 certificate_owner_name_text_anchor,
1102 certificate_owner_name_text_color,
1103 certificate_owner_name_x_pos,
1104 certificate_owner_name_y_pos,
1105 certificate_validate_url_font_size,
1106 certificate_validate_url_text_anchor,
1107 certificate_validate_url_text_color,
1108 certificate_validate_url_x_pos,
1109 certificate_validate_url_y_pos,
1110 overlay_svg_file_upload_id,
1111 overlay_svg_path,
1112 paper_size,
1113 render_certificate_grade
1114 )
1115SELECT uuid_generate_v5($1, id::text),
1116 background_svg_file_upload_id,
1117 background_svg_path,
1118 certificate_date_font_size,
1119 certificate_date_text_anchor,
1120 certificate_date_text_color,
1121 certificate_date_x_pos,
1122 certificate_date_y_pos,
1123 certificate_grade_font_size,
1124 certificate_grade_text_anchor,
1125 certificate_grade_text_color,
1126 certificate_grade_x_pos,
1127 certificate_grade_y_pos,
1128 certificate_locale,
1129 certificate_owner_name_font_size,
1130 certificate_owner_name_text_anchor,
1131 certificate_owner_name_text_color,
1132 certificate_owner_name_x_pos,
1133 certificate_owner_name_y_pos,
1134 certificate_validate_url_font_size,
1135 certificate_validate_url_text_anchor,
1136 certificate_validate_url_text_color,
1137 certificate_validate_url_x_pos,
1138 certificate_validate_url_y_pos,
1139 overlay_svg_file_upload_id,
1140 overlay_svg_path,
1141 paper_size,
1142 render_certificate_grade
1143FROM certificate_configurations
1144WHERE id IN (
1145 SELECT certificate_configuration_id
1146 FROM certificate_configuration_to_requirements cctr
1147 JOIN course_modules cm ON cctr.course_module_id = cm.id
1148 WHERE cm.course_id = $2
1149 AND cctr.deleted_at IS NULL
1150 AND cm.deleted_at IS NULL
1151 )
1152 AND deleted_at IS NULL;
1153 ",
1154 new_course_id,
1155 old_course_id
1156 )
1157 .execute(&mut *tx)
1158 .await?;
1159
1160 sqlx::query!(
1161 "
1162INSERT INTO certificate_configuration_to_requirements (
1163 id,
1164 certificate_configuration_id,
1165 course_module_id
1166 )
1167SELECT uuid_generate_v5($1, cctr.id::text),
1168 uuid_generate_v5($1, cctr.certificate_configuration_id::text),
1169 uuid_generate_v5($1, cctr.course_module_id::text)
1170FROM certificate_configuration_to_requirements cctr
1171 JOIN course_modules cm ON cctr.course_module_id = cm.id
1172 JOIN certificate_configurations cc ON cctr.certificate_configuration_id = cc.id
1173WHERE cm.course_id = $2
1174 AND cctr.deleted_at IS NULL
1175 AND cm.deleted_at IS NULL
1176 AND cc.deleted_at IS NULL;
1177 ",
1178 new_course_id,
1179 old_course_id
1180 )
1181 .execute(&mut *tx)
1182 .await?;
1183
1184 Ok(())
1185}
1186
1187async fn copy_chatbot_configurations(
1188 tx: &mut PgConnection,
1189 new_course_id: Uuid,
1190 old_course_id: Uuid,
1191) -> ModelResult<()> {
1192 sqlx::query!(
1193 "
1194INSERT INTO chatbot_configurations (
1195 id,
1196 course_id,
1197 chatbot_name,
1198 initial_message,
1199 prompt,
1200 use_azure_search,
1201 maintain_azure_search_index,
1202 use_semantic_reranking,
1203 hide_citations,
1204 temperature,
1205 top_p,
1206 presence_penalty,
1207 frequency_penalty,
1208 max_output_tokens,
1209 daily_tokens_per_user,
1210 weekly_tokens_per_user,
1211 default_chatbot,
1212 enabled_to_students,
1213 model_id,
1214 enabled_tool_categories
1215 )
1216SELECT
1217 uuid_generate_v5($1, id::text),
1218 $1,
1219 chatbot_name,
1220 initial_message,
1221 prompt,
1222 use_azure_search,
1223 maintain_azure_search_index,
1224 use_semantic_reranking,
1225 hide_citations,
1226 temperature,
1227 top_p,
1228 presence_penalty,
1229 frequency_penalty,
1230 max_output_tokens,
1231 daily_tokens_per_user,
1232 weekly_tokens_per_user,
1233 default_chatbot,
1234 enabled_to_students,
1235 model_id,
1236 enabled_tool_categories
1237FROM chatbot_configurations
1238WHERE course_id = $2
1239 AND deleted_at IS NULL;
1240 ",
1241 new_course_id,
1242 old_course_id
1243 )
1244 .execute(&mut *tx)
1245 .await?;
1246 Ok(())
1247}
1248
1249async fn copy_cheater_thresholds(
1250 tx: &mut PgConnection,
1251 new_course_id: Uuid,
1252 old_course_id: Uuid,
1253) -> ModelResult<()> {
1254 let old_default_module =
1255 crate::course_modules::get_default_by_course_id(tx, old_course_id).await?;
1256 let new_default_module =
1257 crate::course_modules::get_default_by_course_id(tx, new_course_id).await?;
1258
1259 sqlx::query!(
1260 "
1261INSERT INTO cheater_thresholds (id, course_module_id, duration_seconds)
1262SELECT
1263 uuid_generate_v5($1, id::text),
1264 $2,
1265 duration_seconds
1266FROM cheater_thresholds
1267WHERE course_module_id = $3
1268 AND deleted_at IS NULL;
1269 ",
1270 new_course_id,
1271 new_default_module.id,
1272 old_default_module.id
1273 )
1274 .execute(&mut *tx)
1275 .await?;
1276 Ok(())
1277}
1278
1279async fn copy_course_module_suotar_configurations(
1283 tx: &mut PgConnection,
1284 new_course_id: Uuid,
1285 old_course_id: Uuid,
1286) -> ModelResult<()> {
1287 sqlx::query!(
1288 "
1289INSERT INTO course_module_suotar_configurations (
1290 id,
1291 course_module_id,
1292 open_university_product_id,
1293 grade_scale_id
1294 )
1295SELECT uuid_generate_v5($1, cmsc.id::text),
1296 uuid_generate_v5($1, cmsc.course_module_id::text),
1297 cmsc.open_university_product_id,
1298 cmsc.grade_scale_id
1299FROM course_module_suotar_configurations cmsc
1300 JOIN course_modules cm ON cm.id = cmsc.course_module_id
1301WHERE cm.course_id = $2
1302 AND cmsc.deleted_at IS NULL
1303 AND cm.deleted_at IS NULL
1304 AND (
1305 cmsc.open_university_product_id IS NOT NULL
1306 OR cmsc.grade_scale_id IS NOT NULL
1307 );
1308 ",
1309 new_course_id,
1310 old_course_id
1311 )
1312 .execute(&mut *tx)
1313 .await?;
1314 Ok(())
1315}
1316
1317async fn copy_course_custom_privacy_policy_checkbox_texts(
1318 tx: &mut PgConnection,
1319 new_course_id: Uuid,
1320 old_course_id: Uuid,
1321) -> ModelResult<()> {
1322 sqlx::query!(
1323 "
1324INSERT INTO course_custom_privacy_policy_checkbox_texts (id, course_id, text_slug, text_html)
1325SELECT uuid_generate_v5($1, id::text),
1326 $1,
1327 text_slug,
1328 text_html
1329FROM course_custom_privacy_policy_checkbox_texts
1330WHERE course_id = $2
1331 AND deleted_at IS NULL;
1332 ",
1333 new_course_id,
1334 old_course_id
1335 )
1336 .execute(&mut *tx)
1337 .await?;
1338 Ok(())
1339}
1340
1341async fn copy_exercise_repositories(
1342 tx: &mut PgConnection,
1343 new_course_id: Uuid,
1344 old_course_id: Uuid,
1345) -> ModelResult<()> {
1346 sqlx::query!(
1347 "
1348INSERT INTO exercise_repositories (
1349 id,
1350 course_id,
1351 url,
1352 deploy_key,
1353 public_key,
1354 STATUS,
1355 error_message
1356 )
1357SELECT uuid_generate_v5($1, id::text),
1358 $1,
1359 url,
1360 deploy_key,
1361 public_key,
1362 STATUS,
1363 error_message
1364FROM exercise_repositories
1365WHERE course_id = $2
1366 AND deleted_at IS NULL;
1367 ",
1368 new_course_id,
1369 old_course_id
1370 )
1371 .execute(&mut *tx)
1372 .await?;
1373 Ok(())
1374}
1375
1376async fn copy_partners_blocks(
1377 tx: &mut PgConnection,
1378 new_course_id: Uuid,
1379 old_course_id: Uuid,
1380) -> ModelResult<()> {
1381 sqlx::query!(
1382 "
1383INSERT INTO partners_blocks (id, course_id, content)
1384SELECT uuid_generate_v5($1, id::text),
1385 $1,
1386 content
1387FROM partners_blocks
1388WHERE course_id = $2
1389 AND deleted_at IS NULL;
1390 ",
1391 new_course_id,
1392 old_course_id
1393 )
1394 .execute(&mut *tx)
1395 .await?;
1396 Ok(())
1397}
1398
1399async fn copy_privacy_links(
1400 tx: &mut PgConnection,
1401 new_course_id: Uuid,
1402 old_course_id: Uuid,
1403) -> ModelResult<()> {
1404 sqlx::query!(
1405 "
1406INSERT INTO privacy_links (id, course_id, url, title)
1407SELECT uuid_generate_v5($1, id::text),
1408 $1,
1409 url,
1410 title
1411FROM privacy_links
1412WHERE course_id = $2
1413 AND deleted_at IS NULL;
1414 ",
1415 new_course_id,
1416 old_course_id
1417 )
1418 .execute(&mut *tx)
1419 .await?;
1420 Ok(())
1421}
1422
1423async fn copy_research_consent_forms_and_questions(
1424 tx: &mut PgConnection,
1425 new_course_id: Uuid,
1426 old_course_id: Uuid,
1427) -> ModelResult<()> {
1428 sqlx::query!(
1429 "
1430INSERT INTO course_specific_research_consent_forms (id, course_id, content)
1431SELECT uuid_generate_v5($1, id::text),
1432 $1,
1433 content
1434FROM course_specific_research_consent_forms
1435WHERE course_id = $2
1436 AND deleted_at IS NULL;
1437 ",
1438 new_course_id,
1439 old_course_id
1440 )
1441 .execute(&mut *tx)
1442 .await?;
1443
1444 sqlx::query!(
1445 "
1446INSERT INTO course_specific_consent_form_questions (
1447 id,
1448 course_id,
1449 research_consent_form_id,
1450 question
1451 )
1452SELECT uuid_generate_v5($1, id::text),
1453 $1,
1454 uuid_generate_v5($1, research_consent_form_id::text),
1455 question
1456FROM course_specific_consent_form_questions
1457WHERE course_id = $2
1458 AND deleted_at IS NULL;
1459 ",
1460 new_course_id,
1461 old_course_id
1462 )
1463 .execute(&mut *tx)
1464 .await?;
1465
1466 Ok(())
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471 use super::*;
1472 use crate::{exercise_tasks::ExerciseTask, pages::Page, test_helper::*};
1473 use pretty_assertions::assert_eq;
1474
1475 #[tokio::test]
1476 async fn elg_preserved_when_same_course_language_group() {
1477 insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
1478 :chapter, :page, :exercise);
1479 let original_ex = crate::exercises::get_by_id(tx.as_mut(), exercise)
1480 .await
1481 .unwrap();
1482
1483 let new_meta = create_new_course(org, "fi-FI".into());
1485 let copied_course = copy_course(tx.as_mut(), course, &new_meta, true, user)
1486 .await
1487 .unwrap();
1488
1489 let copied_ex = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
1490 .await
1491 .unwrap()
1492 .pop()
1493 .unwrap();
1494
1495 assert_eq!(
1496 original_ex.exercise_language_group_id, copied_ex.exercise_language_group_id,
1497 "ELG must stay identical when CLG is unchanged"
1498 );
1499 }
1500
1501 #[tokio::test]
1504 async fn elg_deterministic_when_reusing_target_clg() {
1505 insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
1506 :chapter, :page, exercise: _e);
1507
1508 let reusable_clg =
1510 course_language_groups::insert(tx.as_mut(), PKeyPolicy::Generate, "reusable-clg")
1511 .await
1512 .unwrap();
1513
1514 let meta1 = create_new_course(org, "en-US".into());
1515 let copy1 =
1516 copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta1, user)
1517 .await
1518 .unwrap();
1519
1520 let meta2 = {
1521 let mut nc = create_new_course(org, "pt-BR".into());
1522 nc.slug = "copied-course-2".into(); nc
1524 };
1525 let copy2 =
1526 copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta2, user)
1527 .await
1528 .unwrap();
1529
1530 let ex1 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy1.id)
1531 .await
1532 .unwrap()
1533 .pop()
1534 .unwrap();
1535 let ex2 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy2.id)
1536 .await
1537 .unwrap()
1538 .pop()
1539 .unwrap();
1540
1541 assert_ne!(ex1.course_id, ex2.course_id); assert_eq!(
1543 ex1.exercise_language_group_id, ex2.exercise_language_group_id,
1544 "ELG must be deterministic for the same (target CLG, src exercise)"
1545 );
1546 }
1547
1548 #[tokio::test]
1549 async fn copies_course_as_different_course_language_group() {
1550 insert_data!(:tx, :user, :org, :course);
1551 let course = crate::courses::get_course(tx.as_mut(), course)
1552 .await
1553 .unwrap();
1554 let new_course = create_new_course(org, "en-US".into());
1555 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, false, user)
1556 .await
1557 .unwrap();
1558 assert_ne!(
1559 course.course_language_group_id,
1560 copied_course.course_language_group_id
1561 );
1562 }
1563
1564 #[tokio::test]
1565 async fn copies_course_as_same_course_language_group() {
1566 insert_data!(:tx, :user, :org, :course);
1567 let course = crate::courses::get_course(tx.as_mut(), course)
1568 .await
1569 .unwrap();
1570 let new_course = create_new_course(org, "fi-FI".into());
1571 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1572 .await
1573 .unwrap();
1574 assert_eq!(
1575 course.course_language_group_id,
1576 copied_course.course_language_group_id
1577 );
1578 }
1579
1580 #[tokio::test]
1581 async fn copies_course_instances() {
1582 insert_data!(:tx, :user, :org, :course, instance: _instance);
1583 let course = crate::courses::get_course(tx.as_mut(), course)
1584 .await
1585 .unwrap();
1586 let new_course = create_new_course(org, "en-GB".into());
1587 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1588 .await
1589 .unwrap();
1590 let copied_instances =
1591 crate::course_instances::get_course_instances_for_course(tx.as_mut(), copied_course.id)
1592 .await
1593 .unwrap();
1594 assert_eq!(copied_instances.len(), 1);
1595 }
1596
1597 #[tokio::test]
1598 async fn copies_course_modules() {
1599 insert_data!(:tx, :user, :org, :course);
1600 let course = crate::courses::get_course(tx.as_mut(), course)
1601 .await
1602 .unwrap();
1603 let new_course = create_new_course(org, "pt-BR".into());
1604 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1605 .await
1606 .unwrap();
1607
1608 let original_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course.id)
1609 .await
1610 .unwrap();
1611 let copied_modules = crate::course_modules::get_by_course_id(tx.as_mut(), copied_course.id)
1612 .await
1613 .unwrap();
1614 assert_eq!(
1615 original_modules.first().unwrap().id,
1616 copied_modules.first().unwrap().copied_from.unwrap(),
1617 )
1618 }
1619
1620 async fn insert_certificate_configuration(conn: &mut PgConnection) -> Uuid {
1621 let background_svg_file_upload_id = crate::file_uploads::insert(
1622 &mut *conn,
1623 "background.svg",
1624 "certificates/background.svg",
1625 "image/svg+xml",
1626 None,
1627 None,
1628 )
1629 .await
1630 .unwrap();
1631 let configuration = crate::certificate_configurations::DatabaseCertificateConfiguration {
1632 id: Uuid::new_v4(),
1633 certificate_owner_name_y_pos: None,
1634 certificate_owner_name_x_pos: None,
1635 certificate_owner_name_font_size: None,
1636 certificate_owner_name_text_color: None,
1637 certificate_owner_name_text_anchor: None,
1638 certificate_validate_url_y_pos: None,
1639 certificate_validate_url_x_pos: None,
1640 certificate_validate_url_font_size: None,
1641 certificate_validate_url_text_color: None,
1642 certificate_validate_url_text_anchor: None,
1643 certificate_date_y_pos: None,
1644 certificate_date_x_pos: None,
1645 certificate_date_font_size: None,
1646 certificate_date_text_color: None,
1647 certificate_date_text_anchor: None,
1648 certificate_locale: None,
1649 paper_size: None,
1650 background_svg_path: "certificates/background.svg".to_string(),
1651 background_svg_file_upload_id,
1652 overlay_svg_path: None,
1653 overlay_svg_file_upload_id: None,
1654 render_certificate_grade: false,
1655 certificate_grade_y_pos: None,
1656 certificate_grade_x_pos: None,
1657 certificate_grade_font_size: None,
1658 certificate_grade_text_color: None,
1659 certificate_grade_text_anchor: None,
1660 };
1661 crate::certificate_configurations::insert(conn, &configuration)
1662 .await
1663 .unwrap()
1664 .id
1665 }
1666
1667 async fn certificate_configurations_for_course(
1668 conn: &mut PgConnection,
1669 course_id: Uuid,
1670 ) -> Vec<crate::certificate_configurations::CertificateConfigurationAndRequirements> {
1671 crate::certificate_configurations::get_default_certificate_configurations_and_requirements_by_course(
1672 conn, course_id,
1673 )
1674 .await
1675 .unwrap()
1676 }
1677
1678 #[tokio::test]
1679 async fn copies_certificate_configurations() {
1680 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
1681 let configuration = insert_certificate_configuration(tx.as_mut()).await;
1682 crate::certificate_configuration_to_requirements::insert(
1683 tx.as_mut(),
1684 configuration,
1685 Some(course_module.id),
1686 )
1687 .await
1688 .unwrap();
1689
1690 let new_course = create_new_course(org, "en-NZ".into());
1691 let copied_course = copy_course(tx.as_mut(), course, &new_course, true, user)
1692 .await
1693 .unwrap();
1694
1695 let copied = certificate_configurations_for_course(tx.as_mut(), copied_course.id).await;
1696 assert_eq!(copied.len(), 1);
1697 let required_module_id = *copied[0]
1698 .requirements
1699 .course_module_ids
1700 .first()
1701 .expect("the copied configuration should require a module");
1702 let required_module = crate::course_modules::get_by_id(tx.as_mut(), required_module_id)
1703 .await
1704 .unwrap();
1705 assert_eq!(required_module.course_id, copied_course.id);
1706 assert_eq!(required_module.copied_from, Some(course_module.id));
1707 }
1708
1709 #[tokio::test]
1711 async fn skips_requirements_of_deleted_certificate_configurations() {
1712 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
1713 let configuration = insert_certificate_configuration(tx.as_mut()).await;
1714 crate::certificate_configurations::delete(tx.as_mut(), configuration)
1715 .await
1716 .unwrap();
1717 crate::certificate_configuration_to_requirements::insert(
1719 tx.as_mut(),
1720 configuration,
1721 Some(course_module.id),
1722 )
1723 .await
1724 .unwrap();
1725
1726 let new_course = create_new_course(org, "en-IE".into());
1727 let copied_course = copy_course(tx.as_mut(), course, &new_course, true, user)
1728 .await
1729 .unwrap();
1730
1731 assert!(
1732 certificate_configurations_for_course(tx.as_mut(), copied_course.id)
1733 .await
1734 .is_empty()
1735 );
1736 }
1737
1738 #[tokio::test]
1739 async fn copies_course_chapters() {
1740 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter);
1741 let course = crate::courses::get_course(tx.as_mut(), course)
1742 .await
1743 .unwrap();
1744 let new_course = create_new_course(org, "sv-SV".into());
1745 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1746 .await
1747 .unwrap();
1748 let copied_chapters = crate::chapters::get_course_chapters(tx.as_mut(), copied_course.id)
1749 .await
1750 .unwrap();
1751 assert_eq!(copied_chapters.len(), 1);
1752 assert_eq!(copied_chapters.first().unwrap().copied_from, Some(chapter));
1753 }
1754
1755 #[tokio::test]
1756 async fn updates_chapter_front_pages() {
1757 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, chapter: _chapter);
1758 let course = crate::courses::get_course(tx.as_mut(), course)
1759 .await
1760 .unwrap();
1761 let new_course = create_new_course(org, "fr-CA".into());
1762 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1763 .await
1764 .unwrap();
1765 let copied_chapters = crate::chapters::get_course_chapters(tx.as_mut(), copied_course.id)
1766 .await
1767 .unwrap();
1768 let copied_chapter = copied_chapters.first().unwrap();
1769 let copied_chapter_front_page =
1770 crate::pages::get_page(tx.as_mut(), copied_chapter.front_page_id.unwrap())
1771 .await
1772 .unwrap();
1773 assert_eq!(copied_chapter_front_page.course_id, Some(copied_course.id));
1774 }
1775
1776 #[tokio::test]
1777 async fn copies_course_pages() {
1778 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, page: _page);
1779 let course = crate::courses::get_course(tx.as_mut(), course)
1780 .await
1781 .unwrap();
1782 let new_course = create_new_course(org, "es-US".into());
1783 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1784 .await
1785 .unwrap();
1786 let mut original_pages_by_id: HashMap<Uuid, Page> =
1787 crate::pages::get_all_by_course_id_and_visibility(
1788 tx.as_mut(),
1789 course.id,
1790 crate::pages::PageVisibility::Any,
1791 )
1792 .await
1793 .unwrap()
1794 .into_iter()
1795 .map(|page| (page.id, page))
1796 .collect();
1797 assert_eq!(original_pages_by_id.len(), 3);
1798 let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1799 tx.as_mut(),
1800 copied_course.id,
1801 crate::pages::PageVisibility::Any,
1802 )
1803 .await
1804 .unwrap();
1805 assert_eq!(copied_pages.len(), 3);
1806 copied_pages.into_iter().for_each(|copied_page| {
1807 assert!(
1808 original_pages_by_id
1809 .remove(&copied_page.copied_from.unwrap())
1810 .is_some()
1811 );
1812 });
1813 assert!(original_pages_by_id.is_empty());
1814 }
1815
1816 #[tokio::test]
1817 async fn updates_course_slugs_in_internal_links_in_pages_contents() {
1818 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page);
1819 let course = crate::courses::get_course(tx.as_mut(), course)
1820 .await
1821 .unwrap();
1822 crate::pages::update_page_content(
1823 tx.as_mut(),
1824 page,
1825 &serde_json::json!([{
1826 "name": "core/paragraph",
1827 "isValid": true,
1828 "clientId": "b2ecb473-38cc-4df1-84f7-45709cc63e95",
1829 "attributes": {
1830 "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),
1831 "dropCap":false
1832 },
1833 "innerBlocks": []
1834 }]),
1835 )
1836 .await.unwrap();
1837
1838 let new_course = create_new_course(org, "fi-FI".into());
1839 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1840 .await
1841 .unwrap();
1842
1843 let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1844 tx.as_mut(),
1845 copied_course.id,
1846 crate::pages::PageVisibility::Any,
1847 )
1848 .await
1849 .unwrap();
1850 let copied_page = copied_pages
1851 .into_iter()
1852 .find(|copied_page| copied_page.copied_from == Some(page))
1853 .unwrap();
1854 let copied_content_in_page = copied_page.content[0]["attributes"]["content"]
1855 .as_str()
1856 .unwrap();
1857 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>";
1858 assert_eq!(copied_content_in_page, content_with_updated_course_slug);
1859 }
1860
1861 #[tokio::test]
1862 async fn updates_exercise_id_in_content() {
1863 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise);
1864 let course = crate::courses::get_course(tx.as_mut(), course)
1865 .await
1866 .unwrap();
1867 crate::pages::update_page_content(
1868 tx.as_mut(),
1869 page,
1870 &serde_json::json!([{
1871 "name": "moocfi/exercise",
1872 "isValid": true,
1873 "clientId": "b2ecb473-38cc-4df1-84f7-06709cc63e95",
1874 "attributes": {
1875 "id": exercise,
1876 "name": "Exercise"
1877 },
1878 "innerBlocks": []
1879 }]),
1880 )
1881 .await
1882 .unwrap();
1883 let new_course = create_new_course(org, "es-MX".into());
1884 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1885 .await
1886 .unwrap();
1887 let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
1888 tx.as_mut(),
1889 copied_course.id,
1890 crate::pages::PageVisibility::Any,
1891 )
1892 .await
1893 .unwrap();
1894 let copied_page = copied_pages
1895 .into_iter()
1896 .find(|copied_page| copied_page.copied_from == Some(page))
1897 .unwrap();
1898 let copied_exercise_id_in_content =
1899 Uuid::parse_str(copied_page.content[0]["attributes"]["id"].as_str().unwrap()).unwrap();
1900 let copied_exercise =
1901 crate::exercises::get_by_id(tx.as_mut(), copied_exercise_id_in_content)
1902 .await
1903 .unwrap();
1904 assert_eq!(copied_exercise.course_id.unwrap(), copied_course.id);
1905 }
1906
1907 #[tokio::test]
1908 async fn copies_exercises_tasks_and_slides() {
1909 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise, :slide, :task);
1910 let course = crate::courses::get_course(tx.as_mut(), course)
1911 .await
1912 .unwrap();
1913 let new_course = create_new_course(org, "fi-SV".into());
1914 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
1915 .await
1916 .unwrap();
1917 let copied_exercises =
1918 crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
1919 .await
1920 .unwrap();
1921 assert_eq!(copied_exercises.len(), 1);
1922 let copied_exercise = copied_exercises.first().unwrap();
1923 assert_eq!(copied_exercise.copied_from, Some(exercise));
1924 let original_exercise = crate::exercises::get_by_id(tx.as_mut(), exercise)
1925 .await
1926 .unwrap();
1927 assert_eq!(
1928 copied_exercise.max_tries_per_slide,
1929 original_exercise.max_tries_per_slide
1930 );
1931 assert_eq!(
1932 copied_exercise.limit_number_of_tries,
1933 original_exercise.limit_number_of_tries
1934 );
1935 assert_eq!(
1936 copied_exercise.needs_peer_review,
1937 original_exercise.needs_peer_review
1938 );
1939 assert_eq!(
1940 copied_exercise.use_course_default_peer_or_self_review_config,
1941 original_exercise.use_course_default_peer_or_self_review_config
1942 );
1943 let copied_slides = crate::exercise_slides::get_exercise_slides_by_exercise_id(
1944 tx.as_mut(),
1945 copied_exercise.id,
1946 )
1947 .await
1948 .unwrap();
1949 assert_eq!(copied_slides.len(), 1);
1950 let copied_slide = copied_slides.first().unwrap();
1951 let copied_tasks: Vec<ExerciseTask> =
1952 crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_id(
1953 tx.as_mut(),
1954 &copied_slide.id,
1955 )
1956 .await
1957 .unwrap();
1958 assert_eq!(copied_tasks.len(), 1);
1959 let copied_task = copied_tasks.first().unwrap();
1960 assert_eq!(copied_task.copied_from, Some(task));
1961
1962 let original_course_chapters = crate::chapters::get_course_chapters(tx.as_mut(), course.id)
1963 .await
1964 .unwrap();
1965 for original_chapter in original_course_chapters {
1966 for copied_exercise in &copied_exercises {
1967 assert_ne!(original_chapter.id, copied_exercise.id);
1968 }
1969 }
1970 }
1971
1972 #[tokio::test]
1976 async fn copies_the_files_the_specs_declare() {
1977 insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
1978 :chapter, :page, :exercise, :slide, task: task);
1979 let file_upload_id = crate::file_uploads::insert(
1980 tx.as_mut(),
1981 "teacher-example.pdf",
1982 "declaring-service/teacher-example.pdf",
1983 "application/pdf",
1984 None,
1985 None,
1986 )
1987 .await
1988 .unwrap();
1989 crate::exercise_task_spec_files::replace_for_exercise_task(
1990 tx.as_mut(),
1991 task,
1992 crate::exercise_task_spec_files::SpecKind::Private,
1993 &[file_upload_id],
1994 )
1995 .await
1996 .unwrap();
1997
1998 let new_meta = create_new_course(org, "fi-FI".into());
1999 let copied_course = copy_course(tx.as_mut(), course, &new_meta, false, user)
2000 .await
2001 .unwrap();
2002
2003 let copied_exercise =
2004 crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
2005 .await
2006 .unwrap()
2007 .pop()
2008 .unwrap();
2009 let copied_slides = crate::exercise_slides::get_exercise_slides_by_exercise_ids(
2010 tx.as_mut(),
2011 &[copied_exercise.id],
2012 )
2013 .await
2014 .unwrap();
2015 let copied_tasks = crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_ids(
2016 tx.as_mut(),
2017 &copied_slides
2018 .iter()
2019 .map(|slide| slide.id)
2020 .collect::<Vec<_>>(),
2021 )
2022 .await
2023 .unwrap();
2024 let copied_task = copied_tasks.first().expect("the copy has a task");
2025
2026 let declared = crate::exercise_task_spec_files::get_for_exercise_task(
2027 tx.as_mut(),
2028 copied_task.id,
2029 crate::exercise_task_spec_files::SpecKind::Private,
2030 )
2031 .await
2032 .unwrap();
2033 assert_eq!(declared, vec![file_upload_id]);
2034 }
2035
2036 fn create_new_course(organization_id: Uuid, language_code: String) -> NewCourse {
2037 NewCourse {
2038 name: "Copied course".to_string(),
2039 slug: "copied-course".to_string(),
2040 organization_id,
2041 language_code,
2042 teacher_in_charge_name: "Teacher".to_string(),
2043 teacher_in_charge_email: "teacher@example.com".to_string(),
2044 description: "".to_string(),
2045 is_draft: true,
2046 is_test_mode: false,
2047 is_unlisted: false,
2048 copy_user_permissions: false,
2049 is_joinable_by_code_only: false,
2050 join_code: None,
2051 ask_marketing_consent: false,
2052 flagged_answers_threshold: Some(3),
2053 can_add_chatbot: false,
2054 }
2055 }
2056}