1use std::collections::{HashMap, HashSet};
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::page_history::{self, HistoryChangeReason, PageHistoryContent};
17use crate::pages;
18use crate::prelude::*;
19
20pub async fn copy_course(
21 conn: &mut PgConnection,
22 course_id: Uuid,
23 new_course: &NewCourse,
24 same_language_group: bool,
25 user_id: Uuid,
26) -> ModelResult<Course> {
27 let mut tx = conn.begin().await?;
28 let parent_course = get_course(&mut tx, course_id).await?;
29 let course_language_group_id = if same_language_group {
30 parent_course.course_language_group_id
31 } else {
32 course_language_groups::insert(&mut tx, PKeyPolicy::Generate, &new_course.slug).await?
33 };
34
35 let copied_course = copy_course_with_language_group(
36 &mut tx,
37 course_id,
38 course_language_group_id,
39 new_course,
40 user_id,
41 )
42 .await?;
43
44 tx.commit().await?;
45
46 Ok(copied_course)
47}
48
49pub async fn copy_course_with_language_group(
50 conn: &mut PgConnection,
51 src_course_id: Uuid,
52 target_clg_id: Uuid,
53 new_course: &NewCourse,
54 user_id: Uuid,
55) -> ModelResult<Course> {
56 let parent_course = get_course(conn, src_course_id).await?;
57 let same_clg = target_clg_id == parent_course.course_language_group_id;
58 let description = if new_course.description.trim().is_empty() {
59 parent_course.description.clone()
60 } else {
61 Some(new_course.description.clone())
62 };
63
64 let mut tx = conn.begin().await?;
65
66 let copied_course = sqlx::query_as!(
67 Course,
68 r#"
69INSERT INTO courses (
70 name,
71 organization_id,
72 slug,
73 content_search_language,
74 language_code,
75 copied_from,
76 course_language_group_id,
77 is_draft,
78 base_module_completion_requires_n_submodule_completions,
79 can_add_chatbot,
80 is_unlisted,
81 is_joinable_by_code_only,
82 join_code,
83 ask_marketing_consent,
84 description,
85 flagged_answers_threshold,
86 flagged_answers_skip_manual_review_and_allow_retry,
87 cheater_detection_enabled,
88 chapter_locking_enabled,
89 ai_policy,
90 course_material_ai_instructions,
91 is_test_mode
92 )
93VALUES (
94 $1,
95 $2,
96 $3,
97 $4,
98 $5,
99 $6,
100 $7,
101 $8,
102 $9,
103 $10,
104 $11,
105 $12,
106 $13,
107 $14,
108 $15,
109 $16,
110 $17,
111 $18,
112 $19,
113 $20,
114 $21,
115 $22
116 )
117RETURNING id,
118 name,
119 created_at,
120 updated_at,
121 organization_id,
122 deleted_at,
123 slug,
124 content_search_language::text,
125 language_code,
126 copied_from,
127 course_language_group_id,
128 description,
129 is_draft,
130 is_test_mode,
131 base_module_completion_requires_n_submodule_completions,
132 can_add_chatbot,
133 is_unlisted,
134 is_joinable_by_code_only,
135 join_code,
136 ask_marketing_consent,
137 flagged_answers_threshold,
138 flagged_answers_skip_manual_review_and_allow_retry,
139 closed_at,
140 closed_additional_message,
141 closed_course_successor_id,
142 chapter_locking_enabled,
143 cheater_detection_enabled,
144 ai_policy,
145 course_material_ai_instructions
146 "#,
147 new_course.name,
148 new_course.organization_id,
149 new_course.slug,
150 parent_course.content_search_language as _,
151 new_course.language_code,
152 parent_course.id,
153 target_clg_id,
154 new_course.is_draft,
155 parent_course.base_module_completion_requires_n_submodule_completions,
156 parent_course.can_add_chatbot,
157 new_course.is_unlisted,
158 new_course.is_joinable_by_code_only,
159 new_course.join_code,
160 new_course.ask_marketing_consent,
161 description,
162 parent_course.flagged_answers_threshold,
163 parent_course.flagged_answers_skip_manual_review_and_allow_retry,
164 parent_course.cheater_detection_enabled,
165 parent_course.chapter_locking_enabled,
166 parent_course.ai_policy as CourseAiPolicy,
167 parent_course.course_material_ai_instructions,
168 new_course.is_test_mode,
169 )
170 .fetch_one(&mut *tx)
171 .await?;
172
173 let mut content_rewrite = ContentRewrite::new(copied_course.id);
174 content_rewrite.source_course_id = Some(src_course_id);
175 content_rewrite.course_slugs = Some((&parent_course.slug, &new_course.slug));
176
177 content_rewrite.course_module_ids =
178 copy_course_modules(&mut tx, copied_course.id, src_course_id).await?;
179 copy_course_chapters(&mut tx, copied_course.id, src_course_id).await?;
180
181 if new_course.copy_user_permissions {
182 copy_user_permissions(&mut tx, copied_course.id, src_course_id, user_id).await?;
183 }
184
185 let page_contents = copy_course_pages_and_return_contents(
186 &mut tx,
187 copied_course.id,
188 src_course_id,
189 target_clg_id,
190 same_clg,
191 )
192 .await?;
193
194 set_chapter_front_pages(&mut tx, copied_course.id).await?;
195
196 content_rewrite.exercise_ids = copy_course_exercises(
197 &mut tx,
198 copied_course.id,
199 src_course_id,
200 target_clg_id,
201 same_clg,
202 )
203 .await?;
204
205 copy_exercise_slides(&mut tx, copied_course.id, src_course_id).await?;
206 copy_exercise_tasks(&mut tx, copied_course.id, src_course_id).await?;
207
208 let course_instance = course_instances::insert(
210 &mut tx,
211 PKeyPolicy::Generate,
212 NewCourseInstance {
213 course_id: copied_course.id,
214 name: None,
215 description: None,
216 support_email: None,
217 teacher_in_charge_name: &new_course.teacher_in_charge_name,
218 teacher_in_charge_email: &new_course.teacher_in_charge_email,
219 opening_time: None,
220 closing_time: None,
221 },
222 )
223 .await?;
224 content_rewrite.course_instance_id = Some(course_instance.id);
225
226 copy_peer_or_self_review_configs(&mut tx, copied_course.id, src_course_id).await?;
227 copy_peer_or_self_review_questions(&mut tx, copied_course.id, src_course_id).await?;
228 copy_material_references(&mut tx, copied_course.id, src_course_id).await?;
229 copy_glossary_entries(&mut tx, copied_course.id, src_course_id).await?;
230
231 copy_certificate_configurations_and_requirements(&mut tx, copied_course.id, src_course_id)
233 .await?;
234 content_rewrite.chatbot_configuration_ids =
235 copy_chatbot_configurations(&mut tx, copied_course.id, src_course_id).await?;
236 copy_cheater_thresholds(&mut tx, copied_course.id, src_course_id).await?;
237 copy_course_custom_privacy_policy_checkbox_texts(&mut tx, copied_course.id, src_course_id)
238 .await?;
239 copy_exercise_repositories(&mut tx, copied_course.id, src_course_id).await?;
240 copy_partners_blocks(&mut tx, copied_course.id, src_course_id).await?;
241 copy_privacy_links(&mut tx, copied_course.id, src_course_id).await?;
242 content_rewrite.consent_form_question_ids =
243 copy_research_consent_forms_and_questions(&mut tx, copied_course.id, src_course_id).await?;
244 copy_email_templates(&mut tx, copied_course.id, src_course_id).await?;
245 content_rewrite.code_giveaway_ids =
246 copy_code_giveaways(&mut tx, copied_course.id, src_course_id).await?;
247 copy_page_audio_files(&mut tx, copied_course.id, src_course_id).await?;
248
249 let copied_page_ids = page_contents.keys().copied().collect::<Vec<_>>();
250 rewrite_page_contents(&mut tx, page_contents, &content_rewrite).await?;
251 rewrite_course_texts(&mut tx, copied_course.id, &content_rewrite).await?;
252 insert_page_history_for_copies(&mut tx, &copied_page_ids, user_id).await?;
253
254 tx.commit().await?;
255
256 Ok(copied_course)
257}
258
259pub async fn copy_exam(
260 conn: &mut PgConnection,
261 parent_exam_id: &Uuid,
262 new_exam: &NewExam,
263 user_id: Uuid,
264) -> ModelResult<Exam> {
265 let mut tx = conn.begin().await?;
266 let copied_exam = copy_exam_content(&mut tx, parent_exam_id, new_exam, None, user_id).await?;
267 tx.commit().await?;
268 Ok(copied_exam)
269}
270
271async fn copy_exam_content(
272 tx: &mut PgConnection,
273 parent_exam_id: &Uuid,
274 new_exam: &NewExam,
275 new_exam_id: Option<Uuid>,
276 user_id: Uuid,
277) -> ModelResult<Exam> {
278 let parent_exam = exams::get(tx, *parent_exam_id).await?;
279
280 let parent_exam_fields = sqlx::query!(
281 "
282SELECT *
283FROM exams
284WHERE id = $1
285 ",
286 parent_exam.id
287 )
288 .fetch_one(&mut *tx)
289 .await?;
290
291 let final_exam_id = new_exam_id.unwrap_or_else(Uuid::new_v4);
292
293 let copied_exam = sqlx::query!(
295 "
296INSERT INTO exams(
297 id,
298 name,
299 organization_id,
300 instructions,
301 starts_at,
302 ends_at,
303 language,
304 time_minutes,
305 minimum_points_treshold,
306 grade_manually
307 )
308VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
309RETURNING *
310 ",
311 final_exam_id,
312 new_exam.name,
313 parent_exam_fields.organization_id,
314 parent_exam.instructions,
315 new_exam.starts_at,
316 new_exam.ends_at,
317 parent_exam_fields.language,
318 new_exam.time_minutes,
319 new_exam.minimum_points_treshold,
320 new_exam.grade_manually,
321 )
322 .fetch_one(&mut *tx)
323 .await?;
324
325 let page_contents =
326 copy_exam_pages_and_return_contents(&mut *tx, copied_exam.id, parent_exam.id).await?;
327
328 let mut content_rewrite = ContentRewrite::new(copied_exam.id);
329 content_rewrite.exercise_ids =
330 copy_exam_exercises(&mut *tx, copied_exam.id, parent_exam.id).await?;
331
332 copy_exercise_slides(&mut *tx, copied_exam.id, parent_exam.id).await?;
333 copy_exercise_tasks(&mut *tx, copied_exam.id, parent_exam.id).await?;
334 copy_page_audio_files(&mut *tx, copied_exam.id, parent_exam.id).await?;
335
336 let copied_page_ids = page_contents.keys().copied().collect::<Vec<_>>();
337 rewrite_page_contents(&mut *tx, page_contents, &content_rewrite).await?;
338 insert_page_history_for_copies(&mut *tx, &copied_page_ids, user_id).await?;
339
340 let get_page_id = sqlx::query!("SELECT id FROM pages WHERE exam_id = $1;", copied_exam.id)
341 .fetch_one(&mut *tx)
342 .await?;
343
344 Ok(Exam {
345 courses: vec![], ends_at: copied_exam.ends_at,
347 starts_at: copied_exam.starts_at,
348 id: copied_exam.id,
349 instructions: copied_exam.instructions,
350 name: copied_exam.name,
351 time_minutes: copied_exam.time_minutes,
352 page_id: get_page_id.id,
353 minimum_points_treshold: copied_exam.minimum_points_treshold,
354 language: copied_exam
355 .language
356 .unwrap_or_else(|| parent_exam_fields.language.unwrap_or("en-US".to_string())),
357 grade_manually: copied_exam.grade_manually,
358 })
359}
360
361struct ContentRewrite<'a> {
366 namespace_id: Uuid,
367 source_course_id: Option<Uuid>,
368 course_instance_id: Option<Uuid>,
369 course_slugs: Option<(&'a str, &'a str)>,
371 exercise_ids: HashSet<Uuid>,
372 chatbot_configuration_ids: HashSet<Uuid>,
373 course_module_ids: HashSet<Uuid>,
374 code_giveaway_ids: HashSet<Uuid>,
375 consent_form_question_ids: HashSet<Uuid>,
376}
377
378impl<'a> ContentRewrite<'a> {
379 fn new(namespace_id: Uuid) -> Self {
380 Self {
381 namespace_id,
382 source_course_id: None,
383 course_instance_id: None,
384 course_slugs: None,
385 exercise_ids: HashSet::new(),
386 chatbot_configuration_ids: HashSet::new(),
387 course_module_ids: HashSet::new(),
388 code_giveaway_ids: HashSet::new(),
389 consent_form_question_ids: HashSet::new(),
390 }
391 }
392
393 fn copy_of(&self, copied_ids: &HashSet<Uuid>, source_id: &Value) -> Option<Value> {
395 let source_id = Uuid::parse_str(source_id.as_str()?).ok()?;
396 let copied_id = Uuid::new_v5(&self.namespace_id, source_id.to_string().as_bytes());
397 copied_ids
398 .contains(&copied_id)
399 .then(|| Value::String(copied_id.to_string()))
400 }
401
402 fn rewrite_content(&self, content: &Value) -> Value {
403 match content {
404 Value::Array(blocks) => Value::Array(self.rewrite_blocks(blocks.clone())),
405 other => other.clone(),
406 }
407 }
408
409 fn rewrite_blocks(&self, blocks: Vec<Value>) -> Vec<Value> {
410 blocks
411 .into_iter()
412 .filter_map(|block| self.rewrite_block(block))
413 .collect()
414 }
415
416 fn rewrite_block(&self, mut block: Value) -> Option<Value> {
418 let name = block["name"].as_str().unwrap_or_default().to_string();
419 if name == "moocfi/research-consent-question"
420 && let Some(copied_id) =
421 self.copy_of(&self.consent_form_question_ids, &block["clientId"])
422 {
423 block["clientId"] = copied_id;
424 }
425 if let Some(attributes) = block.get_mut("attributes").and_then(Value::as_object_mut) {
426 match name.as_str() {
427 "moocfi/exercise" => {
429 if let Some(source_id) = attributes.get("id").filter(|id| id.is_string()) {
430 let copied_id = self.copy_of(&self.exercise_ids, source_id)?;
431 attributes.insert("id".to_string(), copied_id);
432 }
433 }
434 "moocfi/chatbot" => {
435 if let Some(copied_id) = attributes
436 .get("chatbotConfigurationId")
437 .and_then(|id| self.copy_of(&self.chatbot_configuration_ids, id))
438 {
439 attributes.insert("chatbotConfigurationId".to_string(), copied_id);
440 }
441 if let Some(source_course_id) = self.source_course_id
442 && attributes.get("courseId").and_then(Value::as_str)
443 == Some(source_course_id.to_string().as_str())
444 {
445 attributes.insert(
446 "courseId".to_string(),
447 Value::String(self.namespace_id.to_string()),
448 );
449 }
450 }
451 "moocfi/conditional-block" => {
452 if let Some(Value::Array(module_ids)) = attributes.get_mut("module_completion")
453 {
454 for module_id in module_ids.iter_mut() {
455 if let Some(copied_id) =
456 self.copy_of(&self.course_module_ids, module_id)
457 {
458 *module_id = copied_id;
459 }
460 }
461 }
462 if let Some(course_instance_id) = self.course_instance_id
465 && let Some(Value::Array(instance_ids)) =
466 attributes.get_mut("instance_enrollment")
467 && !instance_ids.is_empty()
468 {
469 *instance_ids = vec![Value::String(course_instance_id.to_string())];
470 }
471 }
472 "moocfi/code-giveaway" => {
473 if let Some(copied_id) = attributes
474 .get("code_giveaway_id")
475 .and_then(|id| self.copy_of(&self.code_giveaway_ids, id))
476 {
477 attributes.insert("code_giveaway_id".to_string(), copied_id);
478 }
479 }
480 _ => {}
481 }
482 if let Some((source_slug, copy_slug)) = self.course_slugs {
483 for (key, value) in attributes.iter_mut() {
484 rewrite_course_links(value, Some(key), source_slug, copy_slug);
485 }
486 }
487 }
488 if let Some(Value::Array(inner_blocks)) = block.get_mut("innerBlocks") {
489 *inner_blocks = self.rewrite_blocks(std::mem::take(inner_blocks));
490 }
491 Some(block)
492 }
493}
494
495fn rewrite_course_links(value: &mut Value, key: Option<&str>, source_slug: &str, copy_slug: &str) {
498 match value {
499 Value::String(text) => {
500 if !text.contains(&format!("/courses/{source_slug}")) {
501 return;
502 }
503 *text = if matches!(key, Some("url" | "href")) {
504 rewrite_course_path(text, source_slug, copy_slug)
505 } else {
506 rewrite_html_hrefs(text, source_slug, copy_slug)
507 };
508 }
509 Value::Array(items) => {
510 for item in items {
511 rewrite_course_links(item, None, source_slug, copy_slug);
512 }
513 }
514 Value::Object(fields) => {
515 for (field_key, field_value) in fields.iter_mut() {
516 rewrite_course_links(field_value, Some(field_key), source_slug, copy_slug);
517 }
518 }
519 _ => {}
520 }
521}
522
523fn rewrite_html_hrefs(html: &str, source_slug: &str, copy_slug: &str) -> String {
524 let mut rewritten = String::with_capacity(html.len());
525 let mut rest = html;
526 while let Some(attribute_start) = rest.find("href=") {
527 let quote_start = attribute_start + "href=".len();
528 let Some(quote) = rest[quote_start..]
529 .chars()
530 .next()
531 .filter(|c| matches!(c, '"' | '\''))
532 else {
533 rewritten.push_str(&rest[..quote_start]);
534 rest = &rest[quote_start..];
535 continue;
536 };
537 let target_start = quote_start + quote.len_utf8();
538 let Some(target_len) = rest[target_start..].find(quote) else {
539 break;
540 };
541 let target_end = target_start + target_len;
542 rewritten.push_str(&rest[..target_start]);
543 rewritten.push_str(&rewrite_course_path(
544 &rest[target_start..target_end],
545 source_slug,
546 copy_slug,
547 ));
548 rest = &rest[target_end..];
549 }
550 rewritten.push_str(rest);
551 rewritten
552}
553
554fn rewrite_course_path(target: &str, source_slug: &str, copy_slug: &str) -> String {
555 let source_path = format!("/courses/{source_slug}");
556 let mut rewritten = String::with_capacity(target.len());
557 let mut rest = target;
558 while let Some(path_start) = rest.find(&source_path) {
559 let path_end = path_start + source_path.len();
560 let ends_at_slug = rest[path_end..]
561 .chars()
562 .next()
563 .is_none_or(|c| matches!(c, '/' | '?' | '#' | '"'));
564 rewritten.push_str(&rest[..path_start]);
565 if ends_at_slug {
566 rewritten.push_str("/courses/");
567 rewritten.push_str(copy_slug);
568 } else {
569 rewritten.push_str(&source_path);
570 }
571 rest = &rest[path_end..];
572 }
573 rewritten.push_str(rest);
574 rewritten
575}
576
577async fn rewrite_page_contents(
578 tx: &mut PgConnection,
579 page_contents: HashMap<Uuid, Value>,
580 content_rewrite: &ContentRewrite<'_>,
581) -> ModelResult<()> {
582 let (ids, contents) = changed_rows(page_contents, |content| {
583 content_rewrite.rewrite_content(content)
584 });
585 if !ids.is_empty() {
586 sqlx::query!(
587 "
588UPDATE pages
589SET content = rewritten.content
590FROM UNNEST($1::uuid [], $2::jsonb []) AS rewritten(id, content)
591WHERE pages.id = rewritten.id;
592 ",
593 &ids,
594 &contents
595 )
596 .execute(&mut *tx)
597 .await?;
598 }
599 Ok(())
600}
601
602fn changed_rows<T: PartialEq>(
604 rows: impl IntoIterator<Item = (Uuid, T)>,
605 rewrite: impl Fn(&T) -> T,
606) -> (Vec<Uuid>, Vec<T>) {
607 rows.into_iter()
608 .filter_map(|(id, value)| {
609 let rewritten = rewrite(&value);
610 (rewritten != value).then_some((id, rewritten))
611 })
612 .unzip()
613}
614
615async fn rewrite_course_texts(
617 tx: &mut PgConnection,
618 course_id: Uuid,
619 content_rewrite: &ContentRewrite<'_>,
620) -> ModelResult<()> {
621 let assignments = sqlx::query!(
622 "
623SELECT t.id,
624 t.assignment
625FROM exercise_tasks t
626 JOIN exercise_slides s ON s.id = t.exercise_slide_id
627 JOIN exercises e ON e.id = s.exercise_id
628WHERE e.course_id = $1
629 AND t.deleted_at IS NULL;
630 ",
631 course_id
632 )
633 .fetch_all(&mut *tx)
634 .await?;
635 let (ids, assignments) = changed_rows(
636 assignments
637 .into_iter()
638 .map(|task| (task.id, task.assignment)),
639 |assignment| content_rewrite.rewrite_content(assignment),
640 );
641 if !ids.is_empty() {
642 sqlx::query!(
643 "
644UPDATE exercise_tasks
645SET assignment = rewritten.assignment
646FROM UNNEST($1::uuid [], $2::jsonb []) AS rewritten(id, assignment)
647WHERE exercise_tasks.id = rewritten.id;
648 ",
649 &ids,
650 &assignments
651 )
652 .execute(&mut *tx)
653 .await?;
654 }
655
656 let review_instructions = sqlx::query!(
657 "
658SELECT id,
659 review_instructions
660FROM peer_or_self_review_configs
661WHERE course_id = $1
662 AND deleted_at IS NULL;
663 ",
664 course_id
665 )
666 .fetch_all(&mut *tx)
667 .await?;
668 let (ids, review_instructions) = changed_rows(
669 review_instructions
670 .into_iter()
671 .filter_map(|config| Some((config.id, config.review_instructions?))),
672 |instructions| content_rewrite.rewrite_content(instructions),
673 );
674 if !ids.is_empty() {
675 sqlx::query!(
676 "
677UPDATE peer_or_self_review_configs
678SET review_instructions = rewritten.review_instructions
679FROM UNNEST($1::uuid [], $2::jsonb []) AS rewritten(id, review_instructions)
680WHERE peer_or_self_review_configs.id = rewritten.id;
681 ",
682 &ids,
683 &review_instructions
684 )
685 .execute(&mut *tx)
686 .await?;
687 }
688
689 let research_forms = sqlx::query!(
690 "
691SELECT id,
692 content
693FROM course_specific_research_consent_forms
694WHERE course_id = $1
695 AND deleted_at IS NULL;
696 ",
697 course_id
698 )
699 .fetch_all(&mut *tx)
700 .await?;
701 let (ids, contents) = changed_rows(
702 research_forms
703 .into_iter()
704 .map(|form| (form.id, form.content)),
705 |content| content_rewrite.rewrite_content(content),
706 );
707 if !ids.is_empty() {
708 sqlx::query!(
709 "
710UPDATE course_specific_research_consent_forms
711SET content = rewritten.content
712FROM UNNEST($1::uuid [], $2::jsonb []) AS rewritten(id, content)
713WHERE course_specific_research_consent_forms.id = rewritten.id;
714 ",
715 &ids,
716 &contents
717 )
718 .execute(&mut *tx)
719 .await?;
720 }
721
722 let email_templates = sqlx::query!(
723 "
724SELECT id,
725 content
726FROM email_templates
727WHERE course_id = $1
728 AND deleted_at IS NULL;
729 ",
730 course_id
731 )
732 .fetch_all(&mut *tx)
733 .await?;
734 let (ids, contents) = changed_rows(
735 email_templates
736 .into_iter()
737 .filter_map(|template| Some((template.id, template.content?))),
738 |content| content_rewrite.rewrite_content(content),
739 );
740 if !ids.is_empty() {
741 sqlx::query!(
742 "
743UPDATE email_templates
744SET content = rewritten.content
745FROM UNNEST($1::uuid [], $2::jsonb []) AS rewritten(id, content)
746WHERE email_templates.id = rewritten.id;
747 ",
748 &ids,
749 &contents
750 )
751 .execute(&mut *tx)
752 .await?;
753 }
754
755 let partners_blocks = sqlx::query!(
756 "
757SELECT id,
758 content
759FROM partners_blocks
760WHERE course_id = $1
761 AND deleted_at IS NULL;
762 ",
763 course_id
764 )
765 .fetch_all(&mut *tx)
766 .await?;
767 let (ids, contents) = changed_rows(
768 partners_blocks
769 .into_iter()
770 .filter_map(|partners_block| Some((partners_block.id, partners_block.content?))),
771 |content| content_rewrite.rewrite_content(content),
772 );
773 if !ids.is_empty() {
774 sqlx::query!(
775 "
776UPDATE partners_blocks
777SET content = rewritten.content
778FROM UNNEST($1::uuid [], $2::jsonb []) AS rewritten(id, content)
779WHERE partners_blocks.id = rewritten.id;
780 ",
781 &ids,
782 &contents
783 )
784 .execute(&mut *tx)
785 .await?;
786 }
787
788 let Some((source_slug, copy_slug)) = content_rewrite.course_slugs else {
789 return Ok(());
790 };
791
792 let checkbox_texts = sqlx::query!(
793 "
794SELECT id,
795 text_html
796FROM course_custom_privacy_policy_checkbox_texts
797WHERE course_id = $1
798 AND deleted_at IS NULL;
799 ",
800 course_id
801 )
802 .fetch_all(&mut *tx)
803 .await?;
804 let (ids, texts) = changed_rows(
805 checkbox_texts
806 .into_iter()
807 .map(|checkbox_text| (checkbox_text.id, checkbox_text.text_html)),
808 |text_html| rewrite_html_hrefs(text_html, source_slug, copy_slug),
809 );
810 if !ids.is_empty() {
811 sqlx::query!(
812 "
813UPDATE course_custom_privacy_policy_checkbox_texts
814SET text_html = rewritten.text_html
815FROM UNNEST($1::uuid [], $2::text []) AS rewritten(id, text_html)
816WHERE course_custom_privacy_policy_checkbox_texts.id = rewritten.id;
817 ",
818 &ids,
819 &texts
820 )
821 .execute(&mut *tx)
822 .await?;
823 }
824
825 let privacy_links = sqlx::query!(
826 "
827SELECT id,
828 url
829FROM privacy_links
830WHERE course_id = $1
831 AND deleted_at IS NULL;
832 ",
833 course_id
834 )
835 .fetch_all(&mut *tx)
836 .await?;
837 let (ids, urls) = changed_rows(
838 privacy_links
839 .into_iter()
840 .map(|privacy_link| (privacy_link.id, privacy_link.url)),
841 |url| rewrite_course_path(url, source_slug, copy_slug),
842 );
843 if !ids.is_empty() {
844 sqlx::query!(
845 "
846UPDATE privacy_links
847SET url = rewritten.url
848FROM UNNEST($1::uuid [], $2::text []) AS rewritten(id, url)
849WHERE privacy_links.id = rewritten.id;
850 ",
851 &ids,
852 &urls
853 )
854 .execute(&mut *tx)
855 .await?;
856 }
857
858 Ok(())
859}
860
861async fn insert_page_history_for_copies(
864 tx: &mut PgConnection,
865 page_ids: &[Uuid],
866 author_user_id: Uuid,
867) -> ModelResult<()> {
868 for page_id in page_ids {
869 let page = pages::get_page_with_exercises(&mut *tx, *page_id).await?;
870 page_history::insert(
871 &mut *tx,
872 PKeyPolicy::Generate,
873 page.page.id,
874 &page.page.title,
875 &PageHistoryContent {
876 content: page.page.content,
877 exercises: page.exercises,
878 exercise_slides: page.exercise_slides,
879 exercise_tasks: page.exercise_tasks,
880 peer_or_self_review_configs: page.peer_or_self_review_configs,
881 peer_or_self_review_questions: page.peer_or_self_review_questions,
882 },
883 HistoryChangeReason::PageSaved,
884 author_user_id,
885 None,
886 )
887 .await?;
888 }
889 Ok(())
890}
891
892async fn copy_course_pages_and_return_contents(
894 tx: &mut PgConnection,
895 namespace_id: Uuid,
896 parent_course_id: Uuid,
897 target_clg_id: Uuid,
898 same_clg: bool,
899) -> ModelResult<HashMap<Uuid, Value>> {
900 let contents = sqlx::query!(
902 "
903WITH src AS (
904 SELECT p.*,
905 CASE
906 WHEN $4 THEN p.page_language_group_id
907 ELSE uuid_generate_v5($3, p.page_language_group_id::text)
908 END AS tgt_plg_id
909 FROM pages p
910 WHERE p.course_id = $2
911 AND p.deleted_at IS NULL
912 AND (
913 p.chapter_id IS NULL
914 OR EXISTS (
915 SELECT 1
916 FROM chapters c
917 WHERE c.id = uuid_generate_v5($1, p.chapter_id::text)
918 )
919 )
920),
921ins_plg AS (
922 INSERT INTO page_language_groups (id, course_language_group_id)
923 SELECT DISTINCT tgt_plg_id,
924 $3
925 FROM src
926 WHERE NOT $4
927 AND tgt_plg_id IS NOT NULL ON CONFLICT (id) DO NOTHING
928)
929INSERT INTO pages (
930 id,
931 course_id,
932 content,
933 url_path,
934 title,
935 chapter_id,
936 order_number,
937 copied_from,
938 content_search_language,
939 page_language_group_id,
940 hidden
941 )
942SELECT uuid_generate_v5($1, src.id::text),
943 $1,
944 src.content,
945 src.url_path,
946 src.title,
947 uuid_generate_v5($1, src.chapter_id::text),
948 src.order_number,
949 src.id,
950 src.content_search_language,
951 src.tgt_plg_id,
952 src.hidden
953FROM src
954RETURNING id,
955 content;
956 ",
957 namespace_id,
958 parent_course_id,
959 target_clg_id,
960 same_clg,
961 )
962 .fetch_all(tx)
963 .await?
964 .into_iter()
965 .map(|record| (record.id, record.content))
966 .collect();
967
968 Ok(contents)
969}
970
971async fn copy_exam_pages_and_return_contents(
972 tx: &mut PgConnection,
973 namespace_id: Uuid,
974 parent_exam_id: Uuid,
975) -> ModelResult<HashMap<Uuid, Value>> {
976 let contents = sqlx::query!(
977 "
978INSERT INTO pages (
979 id,
980 exam_id,
981 content,
982 url_path,
983 title,
984 chapter_id,
985 order_number,
986 copied_from,
987 content_search_language,
988 hidden
989 )
990SELECT uuid_generate_v5($1, id::text),
991 $1,
992 content,
993 url_path,
994 title,
995 NULL,
996 order_number,
997 id,
998 content_search_language,
999 hidden
1000FROM pages
1001WHERE (exam_id = $2)
1002 AND deleted_at IS NULL
1003RETURNING id,
1004 content;
1005 ",
1006 namespace_id,
1007 parent_exam_id
1008 )
1009 .fetch_all(tx)
1010 .await?
1011 .into_iter()
1012 .map(|record| (record.id, record.content))
1013 .collect();
1014
1015 Ok(contents)
1016}
1017
1018async fn set_chapter_front_pages(tx: &mut PgConnection, namespace_id: Uuid) -> ModelResult<()> {
1020 sqlx::query!(
1022 "
1023UPDATE chapters c
1024SET front_page_id = (
1025 SELECT p.id
1026 FROM pages p
1027 WHERE p.id = uuid_generate_v5(c.course_id, c.front_page_id::text)
1028 )
1029WHERE c.course_id = $1
1030 AND c.front_page_id IS NOT NULL;
1031 ",
1032 namespace_id,
1033 )
1034 .execute(&mut *tx)
1035 .await?;
1036
1037 Ok(())
1038}
1039
1040async fn copy_course_modules(
1042 tx: &mut PgConnection,
1043 new_course_id: Uuid,
1044 old_course_id: Uuid,
1045) -> ModelResult<HashSet<Uuid>> {
1046 let copied_ids = sqlx::query!(
1047 "
1048INSERT INTO course_modules (
1049 id,
1050 course_id,
1051 name,
1052 order_number,
1053 copied_from,
1054 automatic_completion,
1055 automatic_completion_number_of_exercises_attempted_treshold,
1056 automatic_completion_number_of_points_treshold,
1057 automatic_completion_requires_exam,
1058 certification_enabled,
1059 completion_registration_link_override,
1060 ects_credits,
1061 enable_registering_completion_to_uh_open_university,
1062 enable_credit_registration_via_suotar,
1063 uh_course_code
1064 )
1065SELECT uuid_generate_v5($1, id::text),
1066 $1,
1067 name,
1068 order_number,
1069 id,
1070 automatic_completion,
1071 automatic_completion_number_of_exercises_attempted_treshold,
1072 automatic_completion_number_of_points_treshold,
1073 automatic_completion_requires_exam,
1074 certification_enabled,
1075 completion_registration_link_override,
1076 ects_credits,
1077 enable_registering_completion_to_uh_open_university,
1078 enable_credit_registration_via_suotar,
1079 uh_course_code
1080FROM course_modules
1081WHERE course_id = $2
1082 AND deleted_at IS NULL
1083RETURNING id
1084 ",
1085 new_course_id,
1086 old_course_id,
1087 )
1088 .fetch_all(&mut *tx)
1089 .await?
1090 .into_iter()
1091 .map(|record| record.id)
1092 .collect();
1093 Ok(copied_ids)
1094}
1095
1096async fn copy_course_chapters(
1098 tx: &mut PgConnection,
1099 namespace_id: Uuid,
1100 parent_course_id: Uuid,
1101) -> ModelResult<()> {
1102 sqlx::query!(
1103 "
1104INSERT INTO chapters (
1105 id,
1106 name,
1107 course_id,
1108 chapter_number,
1109 front_page_id,
1110 opens_at,
1111 chapter_image_path,
1112 copied_from,
1113 course_module_id,
1114 color,
1115 deadline
1116 )
1117SELECT uuid_generate_v5($1, id::text),
1118 name,
1119 $1,
1120 chapter_number,
1121 front_page_id,
1122 opens_at,
1123 chapter_image_path,
1124 id,
1125 uuid_generate_v5($1, course_module_id::text),
1126 color,
1127 deadline
1128FROM chapters
1129WHERE (course_id = $2)
1130 AND deleted_at IS NULL;
1131 ",
1132 namespace_id,
1133 parent_course_id
1134 )
1135 .execute(&mut *tx)
1136 .await?;
1137
1138 Ok(())
1139}
1140
1141async fn copy_course_exercises(
1146 tx: &mut PgConnection,
1147 new_course_id: Uuid,
1148 src_course_id: Uuid,
1149 target_clg_id: Uuid,
1150 same_clg: bool,
1151) -> ModelResult<HashSet<Uuid>> {
1152 let copied_ids = sqlx::query!(
1153 r#"
1154WITH src AS (
1155 SELECT e.*,
1156 copied_page.chapter_id AS tgt_chapter_id,
1157 CASE
1158 WHEN $4 THEN e.exercise_language_group_id
1159 ELSE uuid_generate_v5($3, e.id::text)
1160 END AS tgt_elg_id
1161 FROM exercises e
1162 JOIN pages copied_page ON copied_page.id = uuid_generate_v5($1, e.page_id::text)
1163 WHERE e.course_id = $2
1164 AND e.deleted_at IS NULL
1165),
1166ins_elg AS (
1167 INSERT INTO exercise_language_groups (id, course_language_group_id)
1168 SELECT DISTINCT tgt_elg_id,
1169 $3
1170 FROM src
1171 WHERE NOT $4 ON CONFLICT (id) DO NOTHING
1172),
1173ins_exercises AS (
1174 INSERT INTO exercises (
1175 id,
1176 course_id,
1177 name,
1178 deadline,
1179 page_id,
1180 score_maximum,
1181 order_number,
1182 chapter_id,
1183 copied_from,
1184 exercise_language_group_id,
1185 max_tries_per_slide,
1186 limit_number_of_tries,
1187 needs_peer_review,
1188 use_course_default_peer_or_self_review_config,
1189 needs_self_review,
1190 teacher_reviews_answer_after_locking
1191 )
1192 SELECT uuid_generate_v5($1, src.id::text),
1193 $1,
1194 src.name,
1195 src.deadline,
1196 uuid_generate_v5($1, src.page_id::text),
1197 src.score_maximum,
1198 src.order_number,
1199 src.tgt_chapter_id,
1200 src.id,
1201 src.tgt_elg_id,
1202 src.max_tries_per_slide,
1203 src.limit_number_of_tries,
1204 src.needs_peer_review,
1205 src.use_course_default_peer_or_self_review_config,
1206 src.needs_self_review,
1207 src.teacher_reviews_answer_after_locking
1208 FROM src
1209 RETURNING id
1210)
1211SELECT id
1212FROM ins_exercises;
1213 "#,
1214 new_course_id,
1215 src_course_id,
1216 target_clg_id,
1217 same_clg,
1218 )
1219 .fetch_all(tx)
1220 .await?
1221 .into_iter()
1222 .map(|record| record.id)
1223 .collect();
1224
1225 Ok(copied_ids)
1226}
1227
1228async fn copy_exam_exercises(
1230 tx: &mut PgConnection,
1231 namespace_id: Uuid,
1232 parent_exam_id: Uuid,
1233) -> ModelResult<HashSet<Uuid>> {
1234 let copied_ids = sqlx::query!(
1235 "
1236INSERT INTO exercises (
1237 id,
1238 exam_id,
1239 name,
1240 deadline,
1241 page_id,
1242 score_maximum,
1243 order_number,
1244 chapter_id,
1245 copied_from,
1246 max_tries_per_slide,
1247 limit_number_of_tries,
1248 needs_peer_review,
1249 use_course_default_peer_or_self_review_config,
1250 needs_self_review,
1251 teacher_reviews_answer_after_locking
1252 )
1253SELECT uuid_generate_v5($1, e.id::text),
1254 $1,
1255 e.name,
1256 e.deadline,
1257 copied_page.id,
1258 e.score_maximum,
1259 e.order_number,
1260 NULL,
1261 e.id,
1262 e.max_tries_per_slide,
1263 e.limit_number_of_tries,
1264 e.needs_peer_review,
1265 e.use_course_default_peer_or_self_review_config,
1266 e.needs_self_review,
1267 e.teacher_reviews_answer_after_locking
1268FROM exercises e
1269 JOIN pages copied_page ON copied_page.id = uuid_generate_v5($1, e.page_id::text)
1270WHERE e.exam_id = $2
1271 AND e.deleted_at IS NULL
1272RETURNING id;
1273 ",
1274 namespace_id,
1275 parent_exam_id
1276 )
1277 .fetch_all(tx)
1278 .await?
1279 .into_iter()
1280 .map(|record| record.id)
1281 .collect();
1282
1283 Ok(copied_ids)
1284}
1285
1286async fn copy_exercise_slides(
1287 tx: &mut PgConnection,
1288 namespace_id: Uuid,
1289 parent_id: Uuid,
1290) -> ModelResult<()> {
1291 sqlx::query!(
1292 "
1293INSERT INTO exercise_slides (id, exercise_id, order_number)
1294SELECT uuid_generate_v5($1, s.id::text),
1295 copied_exercise.id,
1296 s.order_number
1297FROM exercise_slides s
1298 JOIN exercises e ON e.id = s.exercise_id
1299 JOIN exercises copied_exercise ON copied_exercise.id = uuid_generate_v5($1, s.exercise_id::text)
1300WHERE (e.course_id = $2 OR e.exam_id = $2)
1301 AND s.deleted_at IS NULL;
1302 ",
1303 namespace_id,
1304 parent_id
1305 )
1306 .execute(&mut *tx)
1307 .await?;
1308
1309 Ok(())
1310}
1311
1312async fn copy_exercise_tasks(
1313 tx: &mut PgConnection,
1314 namespace_id: Uuid,
1315 parent_id: Uuid,
1316) -> ModelResult<()> {
1317 sqlx::query!(
1318 "
1319INSERT INTO exercise_tasks (
1320 id,
1321 exercise_slide_id,
1322 exercise_type,
1323 assignment,
1324 private_spec,
1325 public_spec,
1326 model_solution_spec,
1327 order_number,
1328 copied_from
1329 )
1330SELECT uuid_generate_v5($1, t.id::text),
1331 copied_slide.id,
1332 t.exercise_type,
1333 t.assignment,
1334 t.private_spec,
1335 t.public_spec,
1336 t.model_solution_spec,
1337 t.order_number,
1338 t.id
1339FROM exercise_tasks t
1340 JOIN exercise_slides s ON s.id = t.exercise_slide_id
1341 JOIN exercises e ON e.id = s.exercise_id
1342 JOIN exercise_slides copied_slide ON copied_slide.id = uuid_generate_v5($1, t.exercise_slide_id::text)
1343WHERE (e.course_id = $2 OR e.exam_id = $2)
1344 AND t.deleted_at IS NULL;
1345 ",
1346 namespace_id,
1347 parent_id,
1348 )
1349 .execute(&mut *tx)
1350 .await?;
1351
1352 sqlx::query!(
1356 "
1357INSERT INTO exercise_task_spec_files (exercise_task_id, file_upload_id, spec_kind)
1358SELECT copied_task.id,
1359 f.file_upload_id,
1360 f.spec_kind
1361FROM exercise_task_spec_files f
1362 JOIN exercise_tasks t ON t.id = f.exercise_task_id
1363 JOIN exercise_slides s ON s.id = t.exercise_slide_id
1364 JOIN exercises e ON e.id = s.exercise_id
1365 JOIN exercise_tasks copied_task ON copied_task.id = uuid_generate_v5($1, f.exercise_task_id::text)
1366WHERE (e.course_id = $2 OR e.exam_id = $2)
1367 AND f.deleted_at IS NULL;
1368 ",
1369 namespace_id,
1370 parent_id,
1371 )
1372 .execute(&mut *tx)
1373 .await?;
1374 Ok(())
1375}
1376
1377pub async fn copy_user_permissions(
1378 conn: &mut PgConnection,
1379 new_course_id: Uuid,
1380 old_course_id: Uuid,
1381 user_id: Uuid,
1382) -> ModelResult<()> {
1383 sqlx::query!(
1384 "
1385INSERT INTO roles (
1386 id,
1387 user_id,
1388 organization_id,
1389 course_id,
1390 role
1391 )
1392SELECT uuid_generate_v5($2, id::text),
1393 user_id,
1394 organization_id,
1395 $2,
1396 role
1397FROM roles
1398WHERE (course_id = $1)
1399AND NOT (user_id = $3)
1400AND deleted_at IS NULL;
1401 ",
1402 old_course_id,
1403 new_course_id,
1404 user_id
1405 )
1406 .execute(conn)
1407 .await?;
1408 Ok(())
1409}
1410
1411async fn copy_peer_or_self_review_configs(
1412 tx: &mut PgConnection,
1413 namespace_id: Uuid,
1414 parent_id: Uuid,
1415) -> ModelResult<()> {
1416 sqlx::query!(
1419 "
1420INSERT INTO peer_or_self_review_configs (
1421 id,
1422 course_id,
1423 exercise_id,
1424 peer_reviews_to_give,
1425 peer_reviews_to_receive,
1426 processing_strategy,
1427 accepting_threshold,
1428 manual_review_cutoff_in_days,
1429 points_are_all_or_nothing,
1430 review_instructions,
1431 reset_answer_if_zero_points_from_review
1432 )
1433SELECT uuid_generate_v5($1, posrc.id::text),
1434 $1,
1435 copied_exercise.id,
1436 posrc.peer_reviews_to_give,
1437 posrc.peer_reviews_to_receive,
1438 posrc.processing_strategy,
1439 posrc.accepting_threshold,
1440 posrc.manual_review_cutoff_in_days,
1441 posrc.points_are_all_or_nothing,
1442 posrc.review_instructions,
1443 posrc.reset_answer_if_zero_points_from_review
1444FROM peer_or_self_review_configs posrc
1445 LEFT JOIN exercises e ON (e.id = posrc.exercise_id)
1446 LEFT JOIN exercises copied_exercise ON (
1447 copied_exercise.id = uuid_generate_v5($1, posrc.exercise_id::text)
1448 )
1449WHERE posrc.deleted_at IS NULL
1450 AND (
1451 (
1452 posrc.exercise_id IS NULL
1453 AND posrc.course_id = $2
1454 )
1455 OR (
1456 e.course_id = $2
1457 AND copied_exercise.id IS NOT NULL
1458 )
1459 );
1460 ",
1461 namespace_id,
1462 parent_id,
1463 )
1464 .execute(&mut *tx)
1465 .await?;
1466 Ok(())
1467}
1468
1469async fn copy_peer_or_self_review_questions(
1470 tx: &mut PgConnection,
1471 namespace_id: Uuid,
1472 parent_id: Uuid,
1473) -> ModelResult<()> {
1474 sqlx::query!(
1475 "
1476INSERT INTO peer_or_self_review_questions (
1477 id,
1478 peer_or_self_review_config_id,
1479 order_number,
1480 question,
1481 question_type,
1482 answer_required,
1483 weight
1484 )
1485SELECT uuid_generate_v5($1, q.id::text),
1486 copied_config.id,
1487 q.order_number,
1488 q.question,
1489 q.question_type,
1490 q.answer_required,
1491 q.weight
1492FROM peer_or_self_review_questions q
1493 JOIN peer_or_self_review_configs posrc ON (posrc.id = q.peer_or_self_review_config_id)
1494 JOIN peer_or_self_review_configs copied_config ON (
1495 copied_config.id = uuid_generate_v5($1, q.peer_or_self_review_config_id::text)
1496 )
1497 LEFT JOIN exercises e ON (e.id = posrc.exercise_id)
1498WHERE q.deleted_at IS NULL
1499 AND (
1500 posrc.course_id = $2
1501 OR e.course_id = $2
1502 );
1503 ",
1504 namespace_id,
1505 parent_id,
1506 )
1507 .execute(&mut *tx)
1508 .await?;
1509 Ok(())
1510}
1511
1512async fn copy_material_references(
1513 tx: &mut PgConnection,
1514 namespace_id: Uuid,
1515 parent_id: Uuid,
1516) -> ModelResult<()> {
1517 sqlx::query!(
1519 "
1520INSERT INTO material_references (
1521 citation_key,
1522 course_id,
1523 id,
1524 reference
1525)
1526SELECT citation_key,
1527 $1,
1528 uuid_generate_v5($1, id::text),
1529 reference
1530FROM material_references
1531WHERE course_id = $2
1532AND deleted_at IS NULL;
1533 ",
1534 namespace_id,
1535 parent_id,
1536 )
1537 .execute(&mut *tx)
1538 .await?;
1539 Ok(())
1540}
1541
1542async fn copy_glossary_entries(
1543 tx: &mut PgConnection,
1544 new_course_id: Uuid,
1545 old_course_id: Uuid,
1546) -> ModelResult<()> {
1547 sqlx::query!(
1548 "
1549INSERT INTO glossary (
1550 id,
1551 course_id,
1552 term,
1553 definition
1554 )
1555SELECT uuid_generate_v5($1, id::text),
1556 $1,
1557 term,
1558 definition
1559FROM glossary
1560WHERE course_id = $2
1561 AND deleted_at IS NULL;
1562 ",
1563 new_course_id,
1564 old_course_id,
1565 )
1566 .execute(&mut *tx)
1567 .await?;
1568 Ok(())
1569}
1570
1571async fn copy_certificate_configurations_and_requirements(
1572 tx: &mut PgConnection,
1573 new_course_id: Uuid,
1574 old_course_id: Uuid,
1575) -> ModelResult<()> {
1576 sqlx::query!(
1577 "
1578INSERT INTO certificate_configurations (
1579 id,
1580 background_svg_file_upload_id,
1581 background_svg_path,
1582 certificate_date_font_size,
1583 certificate_date_text_anchor,
1584 certificate_date_text_color,
1585 certificate_date_x_pos,
1586 certificate_date_y_pos,
1587 certificate_grade_font_size,
1588 certificate_grade_text_anchor,
1589 certificate_grade_text_color,
1590 certificate_grade_x_pos,
1591 certificate_grade_y_pos,
1592 certificate_locale,
1593 certificate_owner_name_font_size,
1594 certificate_owner_name_text_anchor,
1595 certificate_owner_name_text_color,
1596 certificate_owner_name_x_pos,
1597 certificate_owner_name_y_pos,
1598 certificate_validate_url_font_size,
1599 certificate_validate_url_text_anchor,
1600 certificate_validate_url_text_color,
1601 certificate_validate_url_x_pos,
1602 certificate_validate_url_y_pos,
1603 overlay_svg_file_upload_id,
1604 overlay_svg_path,
1605 paper_size,
1606 render_certificate_grade
1607 )
1608SELECT uuid_generate_v5($1, id::text),
1609 background_svg_file_upload_id,
1610 background_svg_path,
1611 certificate_date_font_size,
1612 certificate_date_text_anchor,
1613 certificate_date_text_color,
1614 certificate_date_x_pos,
1615 certificate_date_y_pos,
1616 certificate_grade_font_size,
1617 certificate_grade_text_anchor,
1618 certificate_grade_text_color,
1619 certificate_grade_x_pos,
1620 certificate_grade_y_pos,
1621 certificate_locale,
1622 certificate_owner_name_font_size,
1623 certificate_owner_name_text_anchor,
1624 certificate_owner_name_text_color,
1625 certificate_owner_name_x_pos,
1626 certificate_owner_name_y_pos,
1627 certificate_validate_url_font_size,
1628 certificate_validate_url_text_anchor,
1629 certificate_validate_url_text_color,
1630 certificate_validate_url_x_pos,
1631 certificate_validate_url_y_pos,
1632 overlay_svg_file_upload_id,
1633 overlay_svg_path,
1634 paper_size,
1635 render_certificate_grade
1636FROM certificate_configurations
1637WHERE id IN (
1638 SELECT certificate_configuration_id
1639 FROM certificate_configuration_to_requirements cctr
1640 JOIN course_modules cm ON cctr.course_module_id = cm.id
1641 WHERE cm.course_id = $2
1642 AND cctr.deleted_at IS NULL
1643 AND cm.deleted_at IS NULL
1644 )
1645 AND deleted_at IS NULL;
1646 ",
1647 new_course_id,
1648 old_course_id
1649 )
1650 .execute(&mut *tx)
1651 .await?;
1652
1653 sqlx::query!(
1654 "
1655INSERT INTO certificate_configuration_to_requirements (
1656 id,
1657 certificate_configuration_id,
1658 course_module_id
1659 )
1660SELECT uuid_generate_v5($1, cctr.id::text),
1661 uuid_generate_v5($1, cctr.certificate_configuration_id::text),
1662 uuid_generate_v5($1, cctr.course_module_id::text)
1663FROM certificate_configuration_to_requirements cctr
1664 JOIN course_modules cm ON cctr.course_module_id = cm.id
1665 JOIN certificate_configurations cc ON cctr.certificate_configuration_id = cc.id
1666WHERE cm.course_id = $2
1667 AND cctr.deleted_at IS NULL
1668 AND cm.deleted_at IS NULL
1669 AND cc.deleted_at IS NULL;
1670 ",
1671 new_course_id,
1672 old_course_id
1673 )
1674 .execute(&mut *tx)
1675 .await?;
1676
1677 Ok(())
1678}
1679
1680async fn copy_chatbot_configurations(
1682 tx: &mut PgConnection,
1683 new_course_id: Uuid,
1684 old_course_id: Uuid,
1685) -> ModelResult<HashSet<Uuid>> {
1686 let copied_ids = sqlx::query!(
1687 "
1688INSERT INTO chatbot_configurations (
1689 id,
1690 course_id,
1691 chatbot_name,
1692 initial_message,
1693 prompt,
1694 use_azure_search,
1695 maintain_azure_search_index,
1696 use_semantic_reranking,
1697 hide_citations,
1698 temperature,
1699 top_p,
1700 presence_penalty,
1701 frequency_penalty,
1702 max_output_tokens,
1703 daily_tokens_per_user,
1704 weekly_tokens_per_user,
1705 default_chatbot,
1706 enabled_to_students,
1707 model_id,
1708 enabled_tool_categories,
1709 verbosity,
1710 reasoning_effort,
1711 suggest_next_messages,
1712 initial_suggested_messages,
1713 publicly_accessible
1714 )
1715SELECT
1716 uuid_generate_v5($1, id::text),
1717 $1,
1718 chatbot_name,
1719 initial_message,
1720 prompt,
1721 use_azure_search,
1722 maintain_azure_search_index,
1723 use_semantic_reranking,
1724 hide_citations,
1725 temperature,
1726 top_p,
1727 presence_penalty,
1728 frequency_penalty,
1729 max_output_tokens,
1730 daily_tokens_per_user,
1731 weekly_tokens_per_user,
1732 default_chatbot,
1733 enabled_to_students,
1734 model_id,
1735 enabled_tool_categories,
1736 verbosity,
1737 reasoning_effort,
1738 suggest_next_messages,
1739 initial_suggested_messages,
1740 publicly_accessible
1741FROM chatbot_configurations
1742WHERE course_id = $2
1743 AND deleted_at IS NULL
1744RETURNING id;
1745 ",
1746 new_course_id,
1747 old_course_id
1748 )
1749 .fetch_all(&mut *tx)
1750 .await?
1751 .into_iter()
1752 .map(|record| record.id)
1753 .collect();
1754 Ok(copied_ids)
1755}
1756
1757async fn copy_cheater_thresholds(
1758 tx: &mut PgConnection,
1759 new_course_id: Uuid,
1760 old_course_id: Uuid,
1761) -> ModelResult<()> {
1762 sqlx::query!(
1763 "
1764INSERT INTO cheater_thresholds (id, course_module_id, duration_seconds)
1765SELECT uuid_generate_v5($1, ct.id::text),
1766 copied_module.id,
1767 ct.duration_seconds
1768FROM cheater_thresholds ct
1769 JOIN course_modules cm ON cm.id = ct.course_module_id
1770 JOIN course_modules copied_module ON copied_module.id = uuid_generate_v5($1, ct.course_module_id::text)
1771WHERE cm.course_id = $2
1772 AND ct.deleted_at IS NULL;
1773 ",
1774 new_course_id,
1775 old_course_id
1776 )
1777 .execute(&mut *tx)
1778 .await?;
1779 Ok(())
1780}
1781
1782async fn copy_course_custom_privacy_policy_checkbox_texts(
1783 tx: &mut PgConnection,
1784 new_course_id: Uuid,
1785 old_course_id: Uuid,
1786) -> ModelResult<()> {
1787 sqlx::query!(
1788 "
1789INSERT INTO course_custom_privacy_policy_checkbox_texts (id, course_id, text_slug, text_html)
1790SELECT uuid_generate_v5($1, id::text),
1791 $1,
1792 text_slug,
1793 text_html
1794FROM course_custom_privacy_policy_checkbox_texts
1795WHERE course_id = $2
1796 AND deleted_at IS NULL;
1797 ",
1798 new_course_id,
1799 old_course_id
1800 )
1801 .execute(&mut *tx)
1802 .await?;
1803 Ok(())
1804}
1805
1806async fn copy_exercise_repositories(
1807 tx: &mut PgConnection,
1808 new_course_id: Uuid,
1809 old_course_id: Uuid,
1810) -> ModelResult<()> {
1811 sqlx::query!(
1812 "
1813INSERT INTO exercise_repositories (
1814 id,
1815 course_id,
1816 url,
1817 deploy_key,
1818 public_key,
1819 STATUS,
1820 error_message
1821 )
1822SELECT uuid_generate_v5($1, id::text),
1823 $1,
1824 url,
1825 deploy_key,
1826 public_key,
1827 STATUS,
1828 error_message
1829FROM exercise_repositories
1830WHERE course_id = $2
1831 AND deleted_at IS NULL;
1832 ",
1833 new_course_id,
1834 old_course_id
1835 )
1836 .execute(&mut *tx)
1837 .await?;
1838 Ok(())
1839}
1840
1841async fn copy_partners_blocks(
1842 tx: &mut PgConnection,
1843 new_course_id: Uuid,
1844 old_course_id: Uuid,
1845) -> ModelResult<()> {
1846 sqlx::query!(
1847 "
1848INSERT INTO partners_blocks (id, course_id, content)
1849SELECT uuid_generate_v5($1, id::text),
1850 $1,
1851 content
1852FROM partners_blocks
1853WHERE course_id = $2
1854 AND deleted_at IS NULL;
1855 ",
1856 new_course_id,
1857 old_course_id
1858 )
1859 .execute(&mut *tx)
1860 .await?;
1861 Ok(())
1862}
1863
1864async fn copy_privacy_links(
1865 tx: &mut PgConnection,
1866 new_course_id: Uuid,
1867 old_course_id: Uuid,
1868) -> ModelResult<()> {
1869 sqlx::query!(
1870 "
1871INSERT INTO privacy_links (id, course_id, url, title)
1872SELECT uuid_generate_v5($1, id::text),
1873 $1,
1874 url,
1875 title
1876FROM privacy_links
1877WHERE course_id = $2
1878 AND deleted_at IS NULL;
1879 ",
1880 new_course_id,
1881 old_course_id
1882 )
1883 .execute(&mut *tx)
1884 .await?;
1885 Ok(())
1886}
1887
1888async fn copy_research_consent_forms_and_questions(
1891 tx: &mut PgConnection,
1892 new_course_id: Uuid,
1893 old_course_id: Uuid,
1894) -> ModelResult<HashSet<Uuid>> {
1895 sqlx::query!(
1896 "
1897INSERT INTO course_specific_research_consent_forms (id, course_id, content)
1898SELECT uuid_generate_v5($1, id::text),
1899 $1,
1900 content
1901FROM course_specific_research_consent_forms
1902WHERE course_id = $2
1903 AND deleted_at IS NULL;
1904 ",
1905 new_course_id,
1906 old_course_id
1907 )
1908 .execute(&mut *tx)
1909 .await?;
1910
1911 let copied_ids = sqlx::query!(
1912 "
1913INSERT INTO course_specific_consent_form_questions (
1914 id,
1915 course_id,
1916 research_consent_form_id,
1917 question
1918 )
1919SELECT uuid_generate_v5($1, q.id::text),
1920 $1,
1921 copied_form.id,
1922 q.question
1923FROM course_specific_consent_form_questions q
1924 JOIN course_specific_research_consent_forms copied_form ON (
1925 copied_form.id = uuid_generate_v5($1, q.research_consent_form_id::text)
1926 )
1927WHERE q.course_id = $2
1928 AND q.deleted_at IS NULL
1929RETURNING id;
1930 ",
1931 new_course_id,
1932 old_course_id
1933 )
1934 .fetch_all(&mut *tx)
1935 .await?
1936 .into_iter()
1937 .map(|record| record.id)
1938 .collect();
1939
1940 Ok(copied_ids)
1941}
1942
1943async fn copy_email_templates(
1944 tx: &mut PgConnection,
1945 new_course_id: Uuid,
1946 old_course_id: Uuid,
1947) -> ModelResult<()> {
1948 sqlx::query!(
1949 "
1950INSERT INTO email_templates (
1951 id,
1952 course_id,
1953 content,
1954 subject,
1955 exercise_completions_threshold,
1956 points_threshold,
1957 language,
1958 email_template_type
1959 )
1960SELECT uuid_generate_v5($1, id::text),
1961 $1,
1962 content,
1963 subject,
1964 exercise_completions_threshold,
1965 points_threshold,
1966 language,
1967 email_template_type
1968FROM email_templates
1969WHERE course_id = $2
1970 AND deleted_at IS NULL;
1971 ",
1972 new_course_id,
1973 old_course_id
1974 )
1975 .execute(&mut *tx)
1976 .await?;
1977 Ok(())
1978}
1979
1980async fn copy_code_giveaways(
1983 tx: &mut PgConnection,
1984 new_course_id: Uuid,
1985 old_course_id: Uuid,
1986) -> ModelResult<HashSet<Uuid>> {
1987 let copied_ids = sqlx::query!(
1990 "
1991INSERT INTO code_giveaways (
1992 id,
1993 course_id,
1994 course_module_id,
1995 enabled,
1996 require_course_specific_consent_form_question_id,
1997 name
1998 )
1999SELECT uuid_generate_v5($1, cg.id::text),
2000 $1,
2001 copied_module.id,
2002 cg.enabled
2003 AND (
2004 cg.require_course_specific_consent_form_question_id IS NULL
2005 OR copied_question.id IS NOT NULL
2006 ),
2007 copied_question.id,
2008 cg.name
2009FROM code_giveaways cg
2010 LEFT JOIN course_modules copied_module ON (
2011 copied_module.id = uuid_generate_v5($1, cg.course_module_id::text)
2012 )
2013 LEFT JOIN course_specific_consent_form_questions copied_question ON (
2014 copied_question.id = uuid_generate_v5(
2015 $1,
2016 cg.require_course_specific_consent_form_question_id::text
2017 )
2018 )
2019WHERE cg.course_id = $2
2020 AND cg.deleted_at IS NULL
2021RETURNING id;
2022 ",
2023 new_course_id,
2024 old_course_id
2025 )
2026 .fetch_all(&mut *tx)
2027 .await?
2028 .into_iter()
2029 .map(|record| record.id)
2030 .collect();
2031 Ok(copied_ids)
2032}
2033
2034async fn copy_page_audio_files(
2036 tx: &mut PgConnection,
2037 namespace_id: Uuid,
2038 parent_id: Uuid,
2039) -> ModelResult<()> {
2040 sqlx::query!(
2041 "
2042INSERT INTO page_audio_files (id, page_id, path, mime_type)
2043SELECT uuid_generate_v5($1, a.id::text),
2044 copied_page.id,
2045 a.path,
2046 a.mime_type
2047FROM page_audio_files a
2048 JOIN pages p ON p.id = a.page_id
2049 JOIN pages copied_page ON copied_page.id = uuid_generate_v5($1, a.page_id::text)
2050WHERE (p.course_id = $2 OR p.exam_id = $2)
2051 AND a.deleted_at IS NULL;
2052 ",
2053 namespace_id,
2054 parent_id
2055 )
2056 .execute(&mut *tx)
2057 .await?;
2058 Ok(())
2059}
2060
2061#[cfg(test)]
2062mod tests {
2063 use super::*;
2064 use crate::{exercise_tasks::ExerciseTask, pages::Page, test_helper::*};
2065 use pretty_assertions::assert_eq;
2066
2067 #[tokio::test]
2068 async fn elg_preserved_when_same_course_language_group() {
2069 insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
2070 :chapter, :page, :exercise);
2071 let original_ex = crate::exercises::get_by_id(tx.as_mut(), exercise)
2072 .await
2073 .unwrap();
2074
2075 let new_meta = create_new_course(org, "fi-FI".into());
2077 let copied_course = copy_course(tx.as_mut(), course, &new_meta, true, user)
2078 .await
2079 .unwrap();
2080
2081 let copied_ex = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
2082 .await
2083 .unwrap()
2084 .pop()
2085 .unwrap();
2086
2087 assert_eq!(
2088 original_ex.exercise_language_group_id, copied_ex.exercise_language_group_id,
2089 "ELG must stay identical when CLG is unchanged"
2090 );
2091 }
2092
2093 #[tokio::test]
2096 async fn elg_deterministic_when_reusing_target_clg() {
2097 insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
2098 :chapter, :page, exercise: _e);
2099
2100 let reusable_clg =
2102 course_language_groups::insert(tx.as_mut(), PKeyPolicy::Generate, "reusable-clg")
2103 .await
2104 .unwrap();
2105
2106 let meta1 = create_new_course(org, "en-US".into());
2107 let copy1 =
2108 copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta1, user)
2109 .await
2110 .unwrap();
2111
2112 let meta2 = {
2113 let mut nc = create_new_course(org, "pt-BR".into());
2114 nc.slug = "copied-course-2".into(); nc
2116 };
2117 let copy2 =
2118 copy_course_with_language_group(tx.as_mut(), course, reusable_clg, &meta2, user)
2119 .await
2120 .unwrap();
2121
2122 let ex1 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy1.id)
2123 .await
2124 .unwrap()
2125 .pop()
2126 .unwrap();
2127 let ex2 = crate::exercises::get_exercises_by_course_id(tx.as_mut(), copy2.id)
2128 .await
2129 .unwrap()
2130 .pop()
2131 .unwrap();
2132
2133 assert_ne!(ex1.course_id, ex2.course_id); assert_eq!(
2135 ex1.exercise_language_group_id, ex2.exercise_language_group_id,
2136 "ELG must be deterministic for the same (target CLG, src exercise)"
2137 );
2138 }
2139
2140 #[tokio::test]
2141 async fn copies_course_as_different_course_language_group() {
2142 insert_data!(:tx, :user, :org, :course);
2143 let course = crate::courses::get_course(tx.as_mut(), course)
2144 .await
2145 .unwrap();
2146 let new_course = create_new_course(org, "en-US".into());
2147 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, false, user)
2148 .await
2149 .unwrap();
2150 assert_ne!(
2151 course.course_language_group_id,
2152 copied_course.course_language_group_id
2153 );
2154 }
2155
2156 #[tokio::test]
2157 async fn copies_course_as_same_course_language_group() {
2158 insert_data!(:tx, :user, :org, :course);
2159 let course = crate::courses::get_course(tx.as_mut(), course)
2160 .await
2161 .unwrap();
2162 let new_course = create_new_course(org, "fi-FI".into());
2163 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2164 .await
2165 .unwrap();
2166 assert_eq!(
2167 course.course_language_group_id,
2168 copied_course.course_language_group_id
2169 );
2170 }
2171
2172 #[tokio::test]
2173 async fn copies_course_instances() {
2174 insert_data!(:tx, :user, :org, :course, instance: _instance);
2175 let course = crate::courses::get_course(tx.as_mut(), course)
2176 .await
2177 .unwrap();
2178 let new_course = create_new_course(org, "en-GB".into());
2179 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2180 .await
2181 .unwrap();
2182 let copied_instances =
2183 crate::course_instances::get_course_instances_for_course(tx.as_mut(), copied_course.id)
2184 .await
2185 .unwrap();
2186 assert_eq!(copied_instances.len(), 1);
2187 }
2188
2189 #[tokio::test]
2190 async fn copies_course_modules() {
2191 insert_data!(:tx, :user, :org, :course);
2192 let course = crate::courses::get_course(tx.as_mut(), course)
2193 .await
2194 .unwrap();
2195 let new_course = create_new_course(org, "pt-BR".into());
2196 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2197 .await
2198 .unwrap();
2199
2200 let original_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course.id)
2201 .await
2202 .unwrap();
2203 let copied_modules = crate::course_modules::get_by_course_id(tx.as_mut(), copied_course.id)
2204 .await
2205 .unwrap();
2206 assert_eq!(
2207 original_modules.first().unwrap().id,
2208 copied_modules.first().unwrap().copied_from.unwrap(),
2209 )
2210 }
2211
2212 async fn insert_certificate_configuration(conn: &mut PgConnection) -> Uuid {
2213 let background_svg_file_upload_id = crate::file_uploads::insert(
2214 &mut *conn,
2215 "background.svg",
2216 "certificates/background.svg",
2217 "image/svg+xml",
2218 None,
2219 None,
2220 )
2221 .await
2222 .unwrap();
2223 let configuration = crate::certificate_configurations::DatabaseCertificateConfiguration {
2224 id: Uuid::new_v4(),
2225 certificate_owner_name_y_pos: None,
2226 certificate_owner_name_x_pos: None,
2227 certificate_owner_name_font_size: None,
2228 certificate_owner_name_text_color: None,
2229 certificate_owner_name_text_anchor: None,
2230 certificate_validate_url_y_pos: None,
2231 certificate_validate_url_x_pos: None,
2232 certificate_validate_url_font_size: None,
2233 certificate_validate_url_text_color: None,
2234 certificate_validate_url_text_anchor: None,
2235 certificate_date_y_pos: None,
2236 certificate_date_x_pos: None,
2237 certificate_date_font_size: None,
2238 certificate_date_text_color: None,
2239 certificate_date_text_anchor: None,
2240 certificate_locale: None,
2241 paper_size: None,
2242 background_svg_path: "certificates/background.svg".to_string(),
2243 background_svg_file_upload_id,
2244 overlay_svg_path: None,
2245 overlay_svg_file_upload_id: None,
2246 render_certificate_grade: false,
2247 certificate_grade_y_pos: None,
2248 certificate_grade_x_pos: None,
2249 certificate_grade_font_size: None,
2250 certificate_grade_text_color: None,
2251 certificate_grade_text_anchor: None,
2252 };
2253 crate::certificate_configurations::insert(conn, &configuration)
2254 .await
2255 .unwrap()
2256 .id
2257 }
2258
2259 async fn certificate_configurations_for_course(
2260 conn: &mut PgConnection,
2261 course_id: Uuid,
2262 ) -> Vec<crate::certificate_configurations::CertificateConfigurationAndRequirements> {
2263 crate::certificate_configurations::get_default_certificate_configurations_and_requirements_by_course(
2264 conn, course_id,
2265 )
2266 .await
2267 .unwrap()
2268 }
2269
2270 #[tokio::test]
2271 async fn copies_certificate_configurations() {
2272 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
2273 let configuration = insert_certificate_configuration(tx.as_mut()).await;
2274 crate::certificate_configuration_to_requirements::insert(
2275 tx.as_mut(),
2276 configuration,
2277 Some(course_module.id),
2278 )
2279 .await
2280 .unwrap();
2281
2282 let new_course = create_new_course(org, "en-NZ".into());
2283 let copied_course = copy_course(tx.as_mut(), course, &new_course, true, user)
2284 .await
2285 .unwrap();
2286
2287 let copied = certificate_configurations_for_course(tx.as_mut(), copied_course.id).await;
2288 assert_eq!(copied.len(), 1);
2289 let required_module_id = *copied[0]
2290 .requirements
2291 .course_module_ids
2292 .first()
2293 .expect("the copied configuration should require a module");
2294 let required_module = crate::course_modules::get_by_id(tx.as_mut(), required_module_id)
2295 .await
2296 .unwrap();
2297 assert_eq!(required_module.course_id, copied_course.id);
2298 assert_eq!(required_module.copied_from, Some(course_module.id));
2299 }
2300
2301 #[tokio::test]
2303 async fn skips_requirements_of_deleted_certificate_configurations() {
2304 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
2305 let configuration = insert_certificate_configuration(tx.as_mut()).await;
2306 crate::certificate_configurations::delete(tx.as_mut(), configuration)
2307 .await
2308 .unwrap();
2309 crate::certificate_configuration_to_requirements::insert(
2311 tx.as_mut(),
2312 configuration,
2313 Some(course_module.id),
2314 )
2315 .await
2316 .unwrap();
2317
2318 let new_course = create_new_course(org, "en-IE".into());
2319 let copied_course = copy_course(tx.as_mut(), course, &new_course, true, user)
2320 .await
2321 .unwrap();
2322
2323 assert!(
2324 certificate_configurations_for_course(tx.as_mut(), copied_course.id)
2325 .await
2326 .is_empty()
2327 );
2328 }
2329
2330 #[tokio::test]
2331 async fn copies_course_chapters() {
2332 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter);
2333 let course = crate::courses::get_course(tx.as_mut(), course)
2334 .await
2335 .unwrap();
2336 let new_course = create_new_course(org, "sv-SV".into());
2337 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2338 .await
2339 .unwrap();
2340 let copied_chapters = crate::chapters::get_course_chapters(tx.as_mut(), copied_course.id)
2341 .await
2342 .unwrap();
2343 assert_eq!(copied_chapters.len(), 1);
2344 assert_eq!(copied_chapters.first().unwrap().copied_from, Some(chapter));
2345 }
2346
2347 #[tokio::test]
2348 async fn updates_chapter_front_pages() {
2349 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, chapter: _chapter);
2350 let course = crate::courses::get_course(tx.as_mut(), course)
2351 .await
2352 .unwrap();
2353 let new_course = create_new_course(org, "fr-CA".into());
2354 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2355 .await
2356 .unwrap();
2357 let copied_chapters = crate::chapters::get_course_chapters(tx.as_mut(), copied_course.id)
2358 .await
2359 .unwrap();
2360 let copied_chapter = copied_chapters.first().unwrap();
2361 let copied_chapter_front_page =
2362 crate::pages::get_page(tx.as_mut(), copied_chapter.front_page_id.unwrap())
2363 .await
2364 .unwrap();
2365 assert_eq!(copied_chapter_front_page.course_id, Some(copied_course.id));
2366 }
2367
2368 #[tokio::test]
2369 async fn copies_course_pages() {
2370 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, page: _page);
2371 let course = crate::courses::get_course(tx.as_mut(), course)
2372 .await
2373 .unwrap();
2374 let new_course = create_new_course(org, "es-US".into());
2375 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2376 .await
2377 .unwrap();
2378 let mut original_pages_by_id: HashMap<Uuid, Page> =
2379 crate::pages::get_all_by_course_id_and_visibility(
2380 tx.as_mut(),
2381 course.id,
2382 crate::pages::PageVisibility::Any,
2383 )
2384 .await
2385 .unwrap()
2386 .into_iter()
2387 .map(|page| (page.id, page))
2388 .collect();
2389 assert_eq!(original_pages_by_id.len(), 3);
2390 let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
2391 tx.as_mut(),
2392 copied_course.id,
2393 crate::pages::PageVisibility::Any,
2394 )
2395 .await
2396 .unwrap();
2397 assert_eq!(copied_pages.len(), 3);
2398 copied_pages.into_iter().for_each(|copied_page| {
2399 assert!(
2400 original_pages_by_id
2401 .remove(&copied_page.copied_from.unwrap())
2402 .is_some()
2403 );
2404 });
2405 assert!(original_pages_by_id.is_empty());
2406 }
2407
2408 #[tokio::test]
2409 async fn updates_course_slugs_in_internal_links_in_pages_contents() {
2410 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page);
2411 let course = crate::courses::get_course(tx.as_mut(), course)
2412 .await
2413 .unwrap();
2414 crate::pages::update_page_content(
2415 tx.as_mut(),
2416 page,
2417 &serde_json::json!([{
2418 "name": "core/paragraph",
2419 "isValid": true,
2420 "clientId": "b2ecb473-38cc-4df1-84f7-45709cc63e95",
2421 "attributes": {
2422 "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),
2423 "dropCap":false
2424 },
2425 "innerBlocks": []
2426 }]),
2427 )
2428 .await.unwrap();
2429
2430 let new_course = create_new_course(org, "fi-FI".into());
2431 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2432 .await
2433 .unwrap();
2434
2435 let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
2436 tx.as_mut(),
2437 copied_course.id,
2438 crate::pages::PageVisibility::Any,
2439 )
2440 .await
2441 .unwrap();
2442 let copied_page = copied_pages
2443 .into_iter()
2444 .find(|copied_page| copied_page.copied_from == Some(page))
2445 .unwrap();
2446 let copied_content_in_page = copied_page.content[0]["attributes"]["content"]
2447 .as_str()
2448 .unwrap();
2449 let content_with_updated_course_slug = format!(
2450 "Internal link <a href=\"http://project-331.local/org/uh-cs/courses/copied-course\">http://project-331.local/org/uh-cs/courses/{}</a>",
2451 course.slug
2452 );
2453 assert_eq!(copied_content_in_page, content_with_updated_course_slug);
2454 }
2455
2456 #[tokio::test]
2457 async fn updates_exercise_id_in_content() {
2458 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise);
2459 let course = crate::courses::get_course(tx.as_mut(), course)
2460 .await
2461 .unwrap();
2462 crate::pages::update_page_content(
2463 tx.as_mut(),
2464 page,
2465 &serde_json::json!([{
2466 "name": "moocfi/exercise",
2467 "isValid": true,
2468 "clientId": "b2ecb473-38cc-4df1-84f7-06709cc63e95",
2469 "attributes": {
2470 "id": exercise,
2471 "name": "Exercise"
2472 },
2473 "innerBlocks": []
2474 }]),
2475 )
2476 .await
2477 .unwrap();
2478 let new_course = create_new_course(org, "es-MX".into());
2479 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2480 .await
2481 .unwrap();
2482 let copied_pages = crate::pages::get_all_by_course_id_and_visibility(
2483 tx.as_mut(),
2484 copied_course.id,
2485 crate::pages::PageVisibility::Any,
2486 )
2487 .await
2488 .unwrap();
2489 let copied_page = copied_pages
2490 .into_iter()
2491 .find(|copied_page| copied_page.copied_from == Some(page))
2492 .unwrap();
2493 let copied_exercise_id_in_content =
2494 Uuid::parse_str(copied_page.content[0]["attributes"]["id"].as_str().unwrap()).unwrap();
2495 let copied_exercise =
2496 crate::exercises::get_by_id(tx.as_mut(), copied_exercise_id_in_content)
2497 .await
2498 .unwrap();
2499 assert_eq!(copied_exercise.course_id.unwrap(), copied_course.id);
2500 }
2501
2502 #[tokio::test]
2503 async fn copies_exercises_tasks_and_slides() {
2504 insert_data!(:tx, :user, :org, :course, instance: _instance, course_module: _course_module, :chapter, :page, :exercise, :slide, :task);
2505 let course = crate::courses::get_course(tx.as_mut(), course)
2506 .await
2507 .unwrap();
2508 let new_course = create_new_course(org, "fi-SV".into());
2509 let copied_course = copy_course(tx.as_mut(), course.id, &new_course, true, user)
2510 .await
2511 .unwrap();
2512 let copied_exercises =
2513 crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
2514 .await
2515 .unwrap();
2516 assert_eq!(copied_exercises.len(), 1);
2517 let copied_exercise = copied_exercises.first().unwrap();
2518 assert_eq!(copied_exercise.copied_from, Some(exercise));
2519 let original_exercise = crate::exercises::get_by_id(tx.as_mut(), exercise)
2520 .await
2521 .unwrap();
2522 assert_eq!(
2523 copied_exercise.max_tries_per_slide,
2524 original_exercise.max_tries_per_slide
2525 );
2526 assert_eq!(
2527 copied_exercise.limit_number_of_tries,
2528 original_exercise.limit_number_of_tries
2529 );
2530 assert_eq!(
2531 copied_exercise.needs_peer_review,
2532 original_exercise.needs_peer_review
2533 );
2534 assert_eq!(
2535 copied_exercise.use_course_default_peer_or_self_review_config,
2536 original_exercise.use_course_default_peer_or_self_review_config
2537 );
2538 let copied_slides = crate::exercise_slides::get_exercise_slides_by_exercise_id(
2539 tx.as_mut(),
2540 copied_exercise.id,
2541 )
2542 .await
2543 .unwrap();
2544 assert_eq!(copied_slides.len(), 1);
2545 let copied_slide = copied_slides.first().unwrap();
2546 let copied_tasks: Vec<ExerciseTask> =
2547 crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_id(
2548 tx.as_mut(),
2549 &copied_slide.id,
2550 )
2551 .await
2552 .unwrap();
2553 assert_eq!(copied_tasks.len(), 1);
2554 let copied_task = copied_tasks.first().unwrap();
2555 assert_eq!(copied_task.copied_from, Some(task));
2556
2557 let original_course_chapters = crate::chapters::get_course_chapters(tx.as_mut(), course.id)
2558 .await
2559 .unwrap();
2560 for original_chapter in original_course_chapters {
2561 for copied_exercise in &copied_exercises {
2562 assert_ne!(original_chapter.id, copied_exercise.id);
2563 }
2564 }
2565 }
2566
2567 #[tokio::test]
2571 async fn copies_the_files_the_specs_declare() {
2572 insert_data!(:tx, :user, :org, :course, instance: _i, course_module: _m,
2573 :chapter, :page, :exercise, :slide, task: task);
2574 let file_upload_id = crate::file_uploads::insert(
2575 tx.as_mut(),
2576 "teacher-example.pdf",
2577 "declaring-service/teacher-example.pdf",
2578 "application/pdf",
2579 None,
2580 None,
2581 )
2582 .await
2583 .unwrap();
2584 crate::exercise_task_spec_files::replace_for_exercise_task(
2585 tx.as_mut(),
2586 task,
2587 crate::exercise_task_spec_files::SpecKind::Private,
2588 &[file_upload_id],
2589 )
2590 .await
2591 .unwrap();
2592
2593 let new_meta = create_new_course(org, "fi-FI".into());
2594 let copied_course = copy_course(tx.as_mut(), course, &new_meta, false, user)
2595 .await
2596 .unwrap();
2597
2598 let copied_exercise =
2599 crate::exercises::get_exercises_by_course_id(tx.as_mut(), copied_course.id)
2600 .await
2601 .unwrap()
2602 .pop()
2603 .unwrap();
2604 let copied_slides = crate::exercise_slides::get_exercise_slides_by_exercise_ids(
2605 tx.as_mut(),
2606 &[copied_exercise.id],
2607 )
2608 .await
2609 .unwrap();
2610 let copied_tasks = crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_ids(
2611 tx.as_mut(),
2612 &copied_slides
2613 .iter()
2614 .map(|slide| slide.id)
2615 .collect::<Vec<_>>(),
2616 )
2617 .await
2618 .unwrap();
2619 let copied_task = copied_tasks.first().expect("the copy has a task");
2620
2621 let declared = crate::exercise_task_spec_files::get_for_exercise_task(
2622 tx.as_mut(),
2623 copied_task.id,
2624 crate::exercise_task_spec_files::SpecKind::Private,
2625 )
2626 .await
2627 .unwrap();
2628 assert_eq!(declared, vec![file_upload_id]);
2629 }
2630
2631 fn create_new_course(organization_id: Uuid, language_code: String) -> NewCourse {
2632 NewCourse {
2633 name: "Copied course".to_string(),
2634 slug: "copied-course".to_string(),
2635 organization_id,
2636 language_code,
2637 teacher_in_charge_name: "Teacher".to_string(),
2638 teacher_in_charge_email: "teacher@example.com".to_string(),
2639 description: "".to_string(),
2640 is_draft: true,
2641 is_test_mode: false,
2642 is_unlisted: false,
2643 copy_user_permissions: false,
2644 is_joinable_by_code_only: false,
2645 join_code: None,
2646 ask_marketing_consent: false,
2647 flagged_answers_threshold: Some(3),
2648 can_add_chatbot: false,
2649 }
2650 }
2651}