Skip to content

Commit e36d0a5

Browse files
committed
feat: add serializer classes to validate and capture errors
- Add Component and ComponentVersion serializers. The goal is to validate inputs and capture errors consistently. - Refactor the component saving process: it now uses two separate blocks with the bulk draft changes context manager.
1 parent 32dc910 commit e36d0a5

3 files changed

Lines changed: 216 additions & 73 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""
2+
The serializers module for restoration of authoring data.
3+
"""
4+
from openedx_learning.apps.authoring.components import api as components_api
5+
6+
7+
class BaseSerializer:
8+
"""
9+
The base class for all serializers.
10+
Contains basic validation logic.
11+
"""
12+
required_fields: list[str] = []
13+
14+
def __init__(self, data: dict):
15+
self.initial_data = data
16+
self._validated_data: dict | None = None
17+
self.errors: list[str] = []
18+
19+
def is_valid(self) -> bool:
20+
self._validated_data = self.validate(self.initial_data)
21+
return not self.errors
22+
23+
def validate(self, data: dict) -> dict:
24+
"""Override in subclass"""
25+
validated = {}
26+
for field in self.required_fields:
27+
if field not in data:
28+
self.errors.append(f"Missing required field: {field}")
29+
else:
30+
validated[field] = data[field]
31+
return validated
32+
33+
@property
34+
def validated_data(self) -> dict:
35+
if self._validated_data is None:
36+
raise ValueError("Call is_valid() before accessing validated_data")
37+
return self._validated_data
38+
39+
40+
class ComponentSerializer(BaseSerializer):
41+
"""
42+
Serializer for components.
43+
Contains logic to convert entity_key to component_type and local_key.
44+
"""
45+
required_fields = ["can_stand_alone", "key", "created", "created_by"]
46+
47+
def validate(self, data: dict) -> dict:
48+
"""Override in subclass"""
49+
validated = {}
50+
for field in self.required_fields:
51+
if field not in data:
52+
self.errors.append(f"Missing required field: {field}")
53+
else:
54+
validated[field] = data[field]
55+
entity_key = validated["key"]
56+
try:
57+
component_type_obj, local_key = components_api.get_or_create_component_type_by_entity_key(entity_key)
58+
validated["component_type"] = component_type_obj
59+
validated["local_key"] = local_key
60+
except ValueError as exc:
61+
self.errors.append(str(exc))
62+
return validated
63+
64+
65+
class ComponentVersionSerializer(BaseSerializer):
66+
"""
67+
Serializer for component versions.
68+
"""
69+
required_fields = ["title", "entity_key", "created", "created_by", "content_to_replace"]

openedx_learning/apps/authoring/backup_restore/zipper.py

Lines changed: 125 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
PublishableEntity,
2323
PublishableEntityVersion,
2424
)
25+
from openedx_learning.apps.authoring.backup_restore.serializers import ComponentSerializer, ComponentVersionSerializer
2526
from openedx_learning.apps.authoring.backup_restore.toml import (
2627
parse_learning_package_toml,
2728
parse_publishable_entity_toml,
@@ -396,6 +397,7 @@ class LearningPackageUnzipper:
396397
def __init__(self) -> None:
397398
self.utc_now: datetime = datetime.now(tz=timezone.utc)
398399
self.component_types_cache: dict[Tuple[str, str], ComponentType] = {}
400+
self.errors: list[dict[str, Any]] = []
399401

400402
# --------------------------
401403
# Public API
@@ -461,10 +463,98 @@ def _restore_containers(
461463
def _restore_components(
462464
self, zipf: zipfile.ZipFile, component_files: List[str], learning_package: LearningPackage
463465
) -> 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+
)
468558

469559
def _restore_collections(
470560
self, zipf: zipfile.ZipFile, collection_files: List[str], learning_package: LearningPackage
@@ -499,63 +589,12 @@ def _load_container(
499589
)
500590
"""
501591

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
559598

560599
# --------------------------
561600
# Utilities
@@ -640,17 +679,30 @@ def _get_versions_to_write(
640679
draft_version_num = component_data.get("draft", {}).get("version_num")
641680
published_version_num = component_data.get("published", {}).get("version_num")
642681

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}
651684

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+
)
655689

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

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
/,

0 commit comments

Comments
 (0)