|
6 | 6 | import zipfile |
7 | 7 | from datetime import datetime, timezone |
8 | 8 | from pathlib import Path |
9 | | -from typing import Any, List, Optional, Tuple |
| 9 | +from typing import Any, List, Optional, Tuple, TypedDict |
10 | 10 |
|
11 | 11 | from django.db import transaction |
12 | 12 | from django.db.models import Prefetch, QuerySet |
13 | 13 | from django.utils.text import slugify |
14 | 14 |
|
15 | 15 | from openedx_learning.api.authoring_models import ( |
16 | 16 | Collection, |
| 17 | + ComponentType, |
17 | 18 | ComponentVersion, |
18 | 19 | ComponentVersionContent, |
19 | 20 | Content, |
20 | 21 | LearningPackage, |
21 | 22 | PublishableEntity, |
22 | 23 | PublishableEntityVersion, |
23 | 24 | ) |
| 25 | +from openedx_learning.apps.authoring.backup_restore.serializers import ComponentSerializer, ComponentVersionSerializer |
24 | 26 | from openedx_learning.apps.authoring.backup_restore.toml import ( |
25 | 27 | parse_learning_package_toml, |
| 28 | + parse_publishable_entity_toml, |
26 | 29 | toml_collection, |
27 | 30 | toml_learning_package, |
28 | 31 | toml_publishable_entity, |
29 | 32 | ) |
30 | 33 | from openedx_learning.apps.authoring.collections import api as collections_api |
| 34 | +from openedx_learning.apps.authoring.components import api as components_api |
31 | 35 | from openedx_learning.apps.authoring.publishing import api as publishing_api |
32 | 36 |
|
33 | 37 | TOML_PACKAGE_NAME = "package.toml" |
34 | 38 |
|
35 | 39 |
|
| 40 | +class ComponentDefaults(TypedDict): |
| 41 | + content_to_replace: dict[str, int | bytes | None] |
| 42 | + created: datetime |
| 43 | + created_by: Optional[int] |
| 44 | + |
| 45 | + |
36 | 46 | def slugify_hashed_filename(identifier: str) -> str: |
37 | 47 | """ |
38 | 48 | Generate a filesystem-safe filename from an identifier. |
@@ -386,6 +396,8 @@ class LearningPackageUnzipper: |
386 | 396 |
|
387 | 397 | def __init__(self) -> None: |
388 | 398 | self.utc_now: datetime = datetime.now(tz=timezone.utc) |
| 399 | + self.component_types_cache: dict[Tuple[str, str], ComponentType] = {} |
| 400 | + self.errors: list[dict[str, Any]] = [] |
389 | 401 |
|
390 | 402 | # -------------------------- |
391 | 403 | # Public API |
@@ -451,9 +463,98 @@ def _restore_containers( |
451 | 463 | def _restore_components( |
452 | 464 | self, zipf: zipfile.ZipFile, component_files: List[str], learning_package: LearningPackage |
453 | 465 | ) -> None: |
454 | | - """Restore components from the zip archive.""" |
455 | | - for component_file in component_files: |
456 | | - 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(data={ |
| 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 | + ) |
457 | 558 |
|
458 | 559 | def _restore_collections( |
459 | 560 | self, zipf: zipfile.ZipFile, collection_files: List[str], learning_package: LearningPackage |
@@ -488,12 +589,12 @@ def _load_container( |
488 | 589 | ) |
489 | 590 | """ |
490 | 591 |
|
491 | | - def _load_component( |
492 | | - self, zipf: zipfile.ZipFile, component_file: str, learning_package: LearningPackage |
493 | | - ): # pylint: disable=W0613 |
494 | | - """Load and persist a component (placeholder).""" |
495 | | - # TODO: implement actual parsing |
496 | | - return None |
| 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 |
497 | 598 |
|
498 | 599 | # -------------------------- |
499 | 600 | # Utilities |
@@ -529,3 +630,41 @@ def _get_organized_file_list(self, file_paths: List[str]) -> dict[str, Any]: |
529 | 630 | organized["collections"].append(path) |
530 | 631 |
|
531 | 632 | return organized |
| 633 | + |
| 634 | + def _get_versions_to_write( |
| 635 | + self, |
| 636 | + component_version_data: List[dict[str, Any]], |
| 637 | + component_data: dict[str, Any] |
| 638 | + ) -> Tuple[Optional[dict[str, Any]], Optional[dict[str, Any]]]: |
| 639 | + """Return the draft and published versions to write, based on component data.""" |
| 640 | + |
| 641 | + draft_version_num = component_data.get("draft", {}).get("version_num") |
| 642 | + published_version_num = component_data.get("published", {}).get("version_num") |
| 643 | + |
| 644 | + # Build lookup by version_num |
| 645 | + version_lookup = {v.get("version_num"): v for v in component_version_data} |
| 646 | + |
| 647 | + return ( |
| 648 | + version_lookup.get(draft_version_num) if draft_version_num else None, |
| 649 | + version_lookup.get(published_version_num) if published_version_num else None, |
| 650 | + ) |
| 651 | + |
| 652 | + def _validate_versions(self, component_validated_data, draft_version, published_version): |
| 653 | + """ Validate draft and published versions using ComponentVersionSerializer.""" |
| 654 | + valid_versions = {"draft": None, "published": None} |
| 655 | + for label, version in [("draft", draft_version), ("published", published_version)]: |
| 656 | + if version is None: |
| 657 | + continue |
| 658 | + entity_key = component_validated_data["key"] |
| 659 | + version_data = { |
| 660 | + "entity_key": entity_key, |
| 661 | + "content_to_replace": {}, |
| 662 | + "created": self.utc_now, |
| 663 | + "created_by": None, |
| 664 | + **version, |
| 665 | + } |
| 666 | + serializer = ComponentVersionSerializer(data=version_data) |
| 667 | + if not serializer.is_valid(): |
| 668 | + self.errors.append(f"Errors in {label} version for {entity_key}: {serializer.errors}") |
| 669 | + valid_versions[label] = serializer.validated_data |
| 670 | + return valid_versions |
0 commit comments