|
22 | 22 | PublishableEntity, |
23 | 23 | PublishableEntityVersion, |
24 | 24 | ) |
| 25 | +from openedx_learning.apps.authoring.backup_restore.serializers import ComponentSerializer, ComponentVersionSerializer |
25 | 26 | from openedx_learning.apps.authoring.backup_restore.toml import ( |
26 | 27 | parse_learning_package_toml, |
27 | 28 | parse_publishable_entity_toml, |
@@ -396,6 +397,7 @@ class LearningPackageUnzipper: |
396 | 397 | def __init__(self) -> None: |
397 | 398 | self.utc_now: datetime = datetime.now(tz=timezone.utc) |
398 | 399 | self.component_types_cache: dict[Tuple[str, str], ComponentType] = {} |
| 400 | + self.errors: list[dict[str, Any]] = [] |
399 | 401 |
|
400 | 402 | # -------------------------- |
401 | 403 | # Public API |
@@ -461,10 +463,98 @@ def _restore_containers( |
461 | 463 | def _restore_components( |
462 | 464 | self, zipf: zipfile.ZipFile, component_files: List[str], learning_package: LearningPackage |
463 | 465 | ) -> None: |
464 | | - """Restore components from the zip archive.""" |
465 | | - for component_file in component_files: |
466 | | - if component_file.endswith(".toml"): # Only process .toml files |
467 | | - self._load_component(zipf, component_file, learning_package) |
| 466 | + """ |
| 467 | + Restore components and their versions from the zip archive. |
| 468 | + This method validates all components and their versions before persisting any data. |
| 469 | + If any validation errors occur, no data is persisted and errors are collected. |
| 470 | + """ |
| 471 | + |
| 472 | + validated_components = [] |
| 473 | + validated_drafts = [] |
| 474 | + validated_published = [] |
| 475 | + |
| 476 | + for file in component_files: |
| 477 | + if not file.endswith(".toml"): |
| 478 | + # Skip non-TOML files |
| 479 | + continue |
| 480 | + |
| 481 | + # Load component data from the TOML file |
| 482 | + component_data, draft_version, published_version = self._load_component_data(zipf, file) |
| 483 | + |
| 484 | + # Validate component data |
| 485 | + component_serializer = ComponentSerializer({ |
| 486 | + "created": self.utc_now, |
| 487 | + "created_by": None, |
| 488 | + **component_data, |
| 489 | + }) |
| 490 | + if not component_serializer.is_valid(): |
| 491 | + # Collect errors and continue |
| 492 | + self.errors.append({"file": file, "errors": component_serializer.errors}) |
| 493 | + continue |
| 494 | + # Collect component validated data |
| 495 | + validated_components.append(component_serializer.validated_data) |
| 496 | + |
| 497 | + # Load and validate versions |
| 498 | + valid_versions = self._validate_versions( |
| 499 | + component_serializer.validated_data, |
| 500 | + draft_version, |
| 501 | + published_version |
| 502 | + ) |
| 503 | + if valid_versions["draft"]: |
| 504 | + validated_drafts.append(valid_versions["draft"]) |
| 505 | + if valid_versions["published"]: |
| 506 | + validated_published.append(valid_versions["published"]) |
| 507 | + |
| 508 | + if self.errors: |
| 509 | + return |
| 510 | + |
| 511 | + # Persist all validated components and their versions if there are no errors |
| 512 | + self._persist_components(learning_package, validated_components, validated_drafts, validated_published) |
| 513 | + |
| 514 | + def _persist_components( |
| 515 | + self, |
| 516 | + learning_package: LearningPackage, |
| 517 | + validated_components: List[dict[str, Any]], |
| 518 | + validated_drafts: List[dict[str, Any]], |
| 519 | + validated_published: List[dict[str, Any]], |
| 520 | + ) -> None: |
| 521 | + """ |
| 522 | + Persist validated components and their versions to the database. |
| 523 | +
|
| 524 | + The operation is performed within a bulk draft changes context to save |
| 525 | + only one transaction on Draft Change Log. |
| 526 | + """ |
| 527 | + components_by_key = {} # Map entity_key to Component instance |
| 528 | + # Step 1: |
| 529 | + # Create components and their publishable entities |
| 530 | + # Create all published versions as a draft first |
| 531 | + # Publish all drafts |
| 532 | + with publishing_api.bulk_draft_changes_for(learning_package.id): |
| 533 | + for valid_component in validated_components: |
| 534 | + entity_key = valid_component.pop("key") |
| 535 | + component = components_api.create_component( |
| 536 | + learning_package.id, |
| 537 | + **valid_component, |
| 538 | + ) |
| 539 | + components_by_key[entity_key] = component |
| 540 | + |
| 541 | + for valid_draft in validated_published: |
| 542 | + entity_key = valid_draft.pop("entity_key") |
| 543 | + components_api.create_next_component_version( |
| 544 | + components_by_key[entity_key].publishable_entity.id, |
| 545 | + **valid_draft |
| 546 | + ) |
| 547 | + |
| 548 | + publishing_api.publish_all_drafts(learning_package.id) |
| 549 | + |
| 550 | + # Step 2: Create all draft versions |
| 551 | + with publishing_api.bulk_draft_changes_for(learning_package.id): |
| 552 | + for valid_draft in validated_drafts: |
| 553 | + entity_key = valid_draft.pop("entity_key") |
| 554 | + components_api.create_next_component_version( |
| 555 | + components_by_key[entity_key].publishable_entity.id, |
| 556 | + **valid_draft |
| 557 | + ) |
468 | 558 |
|
469 | 559 | def _restore_collections( |
470 | 560 | self, zipf: zipfile.ZipFile, collection_files: List[str], learning_package: LearningPackage |
@@ -499,63 +589,12 @@ def _load_container( |
499 | 589 | ) |
500 | 590 | """ |
501 | 591 |
|
502 | | - def _load_component( |
503 | | - self, zipf: zipfile.ZipFile, component_file_path: str, learning_package: "LearningPackage" |
504 | | - ): |
505 | | - """Load and persist a component and its versions.""" |
506 | | - component_content = self._read_file_from_zip(zipf, component_file_path) |
507 | | - component_data, component_version_data = parse_publishable_entity_toml(component_content) |
508 | | - |
509 | | - with publishing_api.bulk_draft_changes_for(learning_package.id): |
510 | | - # Step 1: Create the component and the associated publishable entity |
511 | | - entity_key = component_data["key"] |
512 | | - component_type, local_key = self._get_component_type(entity_key) |
513 | | - if component_type is None or local_key is None: |
514 | | - raise ValueError(f"Component type not found from key: {entity_key}") |
515 | | - component = components_api.create_component( |
516 | | - learning_package.id, |
517 | | - component_type=component_type, |
518 | | - local_key=local_key, |
519 | | - created=self.utc_now, |
520 | | - created_by=None, |
521 | | - can_stand_alone=component_data["can_stand_alone"], |
522 | | - ) |
523 | | - |
524 | | - # Step 2: Determine which versions to create |
525 | | - draft_version, published_version = self._get_versions_to_write(component_version_data, component_data) |
526 | | - |
527 | | - component_default_kwargs: ComponentDefaults = { |
528 | | - "content_to_replace": {}, |
529 | | - "created": self.utc_now, |
530 | | - "created_by": None, |
531 | | - } |
532 | | - |
533 | | - # Step 3: Create the published version if it exists |
534 | | - if published_version: |
535 | | - components_api.create_next_component_version( |
536 | | - component.publishable_entity.id, |
537 | | - **component_default_kwargs, |
538 | | - title=published_version["title"], |
539 | | - ) |
540 | | - # At this point, we have a draft version created as well, so we need to publish it |
541 | | - # Note: We can not create a published version directly, |
542 | | - # we need to create a draft first and then publish it |
543 | | - # That's why we publish right after creating the published |
544 | | - # version and before creating the draft version |
545 | | - publishing_api.publish_from_drafts( |
546 | | - learning_package.id, |
547 | | - draft_qset=publishing_api.get_all_drafts(learning_package.pk).filter( |
548 | | - entity=component.publishable_entity |
549 | | - ), |
550 | | - ) |
551 | | - |
552 | | - # Step 4: Create the draft version if it exists |
553 | | - if draft_version: |
554 | | - components_api.create_next_component_version( |
555 | | - component.publishable_entity.id, |
556 | | - **component_default_kwargs, |
557 | | - title=draft_version["title"], |
558 | | - ) |
| 592 | + def _load_component_data(self, zipf, component_file): |
| 593 | + """Load component data and its versions from a TOML file.""" |
| 594 | + content = self._read_file_from_zip(zipf, component_file) |
| 595 | + component_data, component_version_data = parse_publishable_entity_toml(content) |
| 596 | + draft_version, published_version = self._get_versions_to_write(component_version_data, component_data) |
| 597 | + return component_data, draft_version, published_version |
559 | 598 |
|
560 | 599 | # -------------------------- |
561 | 600 | # Utilities |
@@ -640,17 +679,30 @@ def _get_versions_to_write( |
640 | 679 | draft_version_num = component_data.get("draft", {}).get("version_num") |
641 | 680 | published_version_num = component_data.get("published", {}).get("version_num") |
642 | 681 |
|
643 | | - draft_version = None |
644 | | - published_version = None |
645 | | - |
646 | | - for version in component_version_data: |
647 | | - version_num = version.get("version_num") |
648 | | - |
649 | | - if version_num == published_version_num: |
650 | | - published_version = version |
| 682 | + # Build lookup by version_num |
| 683 | + version_lookup = {v.get("version_num"): v for v in component_version_data} |
651 | 684 |
|
652 | | - # Only assign draft if it’s not the same as published |
653 | | - if version_num == draft_version_num and version_num != published_version_num: |
654 | | - draft_version = version |
| 685 | + return ( |
| 686 | + version_lookup.get(draft_version_num) if draft_version_num else None, |
| 687 | + version_lookup.get(published_version_num) if published_version_num else None, |
| 688 | + ) |
655 | 689 |
|
656 | | - return draft_version, published_version |
| 690 | + def _validate_versions(self, component_validated_data, draft_version, published_version): |
| 691 | + """ Validate draft and published versions using ComponentVersionSerializer.""" |
| 692 | + valid_versions = {"draft": None, "published": None} |
| 693 | + for label, version in [("draft", draft_version), ("published", published_version)]: |
| 694 | + if version is None: |
| 695 | + continue |
| 696 | + entity_key = component_validated_data["key"] |
| 697 | + version_data = { |
| 698 | + "entity_key": entity_key, |
| 699 | + "content_to_replace": {}, |
| 700 | + "created": self.utc_now, |
| 701 | + "created_by": None, |
| 702 | + **version, |
| 703 | + } |
| 704 | + serializer = ComponentVersionSerializer(version_data) |
| 705 | + if not serializer.is_valid(): |
| 706 | + self.errors.append(f"Errors in {label} version for {entity_key}: {serializer.errors}") |
| 707 | + valid_versions[label] = serializer.validated_data |
| 708 | + return valid_versions |
0 commit comments