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