Skip to content

Commit 3104fe8

Browse files
authored
feat: add load process for components and their versions (#390)
1 parent 3241450 commit 3104fe8

File tree

5 files changed

+266
-11
lines changed

5 files changed

+266
-11
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""
2+
The serializers module for restoration of authoring data.
3+
"""
4+
from rest_framework import serializers
5+
6+
from openedx_learning.apps.authoring.components import api as components_api
7+
8+
9+
class ComponentSerializer(serializers.Serializer): # pylint: disable=abstract-method
10+
"""
11+
Serializer for components.
12+
Contains logic to convert entity_key to component_type and local_key.
13+
"""
14+
can_stand_alone = serializers.BooleanField(required=True)
15+
key = serializers.CharField(required=True)
16+
created = serializers.DateTimeField(required=True)
17+
created_by = serializers.CharField(required=True, allow_null=True)
18+
19+
def validate(self, attrs):
20+
"""
21+
Custom validation logic:
22+
parse the entity_key into (component_type, local_key).
23+
"""
24+
entity_key = attrs["key"]
25+
try:
26+
component_type_obj, local_key = components_api.get_or_create_component_type_by_entity_key(entity_key)
27+
attrs["component_type"] = component_type_obj
28+
attrs["local_key"] = local_key
29+
except ValueError as exc:
30+
raise serializers.ValidationError({"key": str(exc)})
31+
return attrs
32+
33+
34+
class ComponentVersionSerializer(serializers.Serializer): # pylint: disable=abstract-method
35+
"""
36+
Serializer for component versions.
37+
"""
38+
title = serializers.CharField(required=True)
39+
entity_key = serializers.CharField(required=True)
40+
created = serializers.DateTimeField(required=True)
41+
created_by = serializers.CharField(required=True, allow_null=True)
42+
content_to_replace = serializers.DictField(child=serializers.CharField(), required=True)

openedx_learning/apps/authoring/backup_restore/toml.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,6 @@ def toml_publishable_entity(
117117
children = []
118118
119119
[version.container.unit]
120-
graded = true
121120
"""
122121
entity_table = _get_toml_publishable_entity_table(entity, draft_version, published_version)
123122
doc = tomlkit.document()
@@ -219,3 +218,21 @@ def parse_learning_package_toml(content: str) -> dict:
219218
if "key" not in lp_data["learning_package"]:
220219
raise ValueError("Invalid learning package TOML: missing 'key' in 'learning_package' section")
221220
return lp_data["learning_package"]
221+
222+
223+
def parse_publishable_entity_toml(content: str) -> tuple[Dict[str, Any], list]:
224+
"""
225+
Parse the publishable entity TOML file and return a dict of its fields.
226+
"""
227+
pe_data: Dict[str, Any] = tomlkit.parse(content)
228+
229+
# Validate the minimum required fields
230+
if "entity" not in pe_data:
231+
raise ValueError("Invalid publishable entity TOML: missing 'entity' section")
232+
if "version" not in pe_data:
233+
raise ValueError("Invalid publishable entity TOML: missing 'version' section")
234+
if "key" not in pe_data["entity"]:
235+
raise ValueError("Invalid publishable entity TOML: missing 'key' field")
236+
if "can_stand_alone" not in pe_data["entity"]:
237+
raise ValueError("Invalid publishable entity TOML: missing 'can_stand_alone' field")
238+
return pe_data["entity"], pe_data.get("version", [])

openedx_learning/apps/authoring/backup_restore/zipper.py

Lines changed: 149 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,43 @@
66
import zipfile
77
from datetime import datetime, timezone
88
from pathlib import Path
9-
from typing import Any, List, Optional, Tuple
9+
from typing import Any, List, Optional, Tuple, TypedDict
1010

1111
from django.db import transaction
1212
from django.db.models import Prefetch, QuerySet
1313
from django.utils.text import slugify
1414

1515
from openedx_learning.api.authoring_models import (
1616
Collection,
17+
ComponentType,
1718
ComponentVersion,
1819
ComponentVersionContent,
1920
Content,
2021
LearningPackage,
2122
PublishableEntity,
2223
PublishableEntityVersion,
2324
)
25+
from openedx_learning.apps.authoring.backup_restore.serializers import ComponentSerializer, ComponentVersionSerializer
2426
from openedx_learning.apps.authoring.backup_restore.toml import (
2527
parse_learning_package_toml,
28+
parse_publishable_entity_toml,
2629
toml_collection,
2730
toml_learning_package,
2831
toml_publishable_entity,
2932
)
3033
from openedx_learning.apps.authoring.collections import api as collections_api
34+
from openedx_learning.apps.authoring.components import api as components_api
3135
from openedx_learning.apps.authoring.publishing import api as publishing_api
3236

3337
TOML_PACKAGE_NAME = "package.toml"
3438

3539

40+
class ComponentDefaults(TypedDict):
41+
content_to_replace: dict[str, int | bytes | None]
42+
created: datetime
43+
created_by: Optional[int]
44+
45+
3646
def slugify_hashed_filename(identifier: str) -> str:
3747
"""
3848
Generate a filesystem-safe filename from an identifier.
@@ -386,6 +396,8 @@ class LearningPackageUnzipper:
386396

387397
def __init__(self) -> None:
388398
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]] = []
389401

390402
# --------------------------
391403
# Public API
@@ -451,9 +463,98 @@ def _restore_containers(
451463
def _restore_components(
452464
self, zipf: zipfile.ZipFile, component_files: List[str], learning_package: LearningPackage
453465
) -> 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+
)
457558

458559
def _restore_collections(
459560
self, zipf: zipfile.ZipFile, collection_files: List[str], learning_package: LearningPackage
@@ -488,12 +589,12 @@ def _load_container(
488589
)
489590
"""
490591

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
497598

498599
# --------------------------
499600
# Utilities
@@ -529,3 +630,41 @@ def _get_organized_file_list(self, file_paths: List[str]) -> dict[str, Any]:
529630
organized["collections"].append(path)
530631

531632
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

openedx_learning/apps/authoring/components/api.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
# to be callable only by other apps in the authoring package.
3535
__all__ = [
3636
"get_or_create_component_type",
37+
"get_or_create_component_type_by_entity_key",
3738
"create_component",
3839
"create_component_version",
3940
"create_next_component_version",
@@ -73,6 +74,27 @@ def get_or_create_component_type(namespace: str, name: str) -> ComponentType:
7374
return component_type
7475

7576

77+
def get_or_create_component_type_by_entity_key(entity_key: str) -> tuple[ComponentType, str]:
78+
"""
79+
Get or create a ComponentType based on a full entity key string.
80+
81+
The entity key is expected to be in the format
82+
``"{namespace}:{type_name}:{local_key}"``. This function will parse out the
83+
``namespace`` and ``type_name`` parts and use those to get or create the
84+
ComponentType.
85+
86+
Raises ValueError if the entity_key is not in the expected format.
87+
"""
88+
try:
89+
namespace, type_name, local_key = entity_key.split(':', 2)
90+
except ValueError as exc:
91+
raise ValueError(
92+
f"Invalid entity_key format: {entity_key!r}. "
93+
"Expected format: '{namespace}:{type_name}:{local_key}'"
94+
) from exc
95+
return get_or_create_component_type(namespace, type_name), local_key
96+
97+
7698
def create_component(
7799
learning_package_id: int,
78100
/,

tests/openedx_learning/apps/authoring/components/test_api.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,3 +605,38 @@ def setUpTestData(cls) -> None:
605605
username="user",
606606
607607
)
608+
609+
610+
class TestComponentTypeUtils(TestCase):
611+
"""
612+
Test the component type utility functions.
613+
"""
614+
615+
def test_get_or_create_component_type_by_entity_key_creates_new(self):
616+
comp_type, local_key = components_api.get_or_create_component_type_by_entity_key(
617+
"video:youtube:abcd1234"
618+
)
619+
620+
assert isinstance(comp_type, ComponentType)
621+
assert comp_type.namespace == "video"
622+
assert comp_type.name == "youtube"
623+
assert local_key == "abcd1234"
624+
assert ComponentType.objects.count() == 1
625+
626+
def test_get_or_create_component_type_by_entity_key_existing(self):
627+
ComponentType.objects.create(namespace="video", name="youtube")
628+
629+
comp_type, local_key = components_api.get_or_create_component_type_by_entity_key(
630+
"video:youtube:efgh5678"
631+
)
632+
633+
assert comp_type.namespace == "video"
634+
assert comp_type.name == "youtube"
635+
assert local_key == "efgh5678"
636+
assert ComponentType.objects.count() == 1
637+
638+
def test_get_or_create_component_type_by_entity_key_invalid_format(self):
639+
with self.assertRaises(ValueError) as ctx:
640+
components_api.get_or_create_component_type_by_entity_key("not-enough-parts")
641+
642+
self.assertIn("Invalid entity_key format", str(ctx.exception))

0 commit comments

Comments
 (0)