Skip to content

Commit ce359a2

Browse files
authored
feat: import sections, subsections, and units from TOML into Learning Core (openedx#392)
1 parent ffa8da6 commit ce359a2

6 files changed

Lines changed: 403 additions & 211 deletions

File tree

openedx_learning/apps/authoring/backup_restore/serializers.py

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,32 @@
66
from openedx_learning.apps.authoring.components import api as components_api
77

88

9-
class ComponentSerializer(serializers.Serializer): # pylint: disable=abstract-method
9+
class EntitySerializer(serializers.Serializer): # pylint: disable=abstract-method
1010
"""
11-
Serializer for components.
12-
Contains logic to convert entity_key to component_type and local_key.
11+
Serializer for publishable entities.
1312
"""
1413
can_stand_alone = serializers.BooleanField(required=True)
1514
key = serializers.CharField(required=True)
1615
created = serializers.DateTimeField(required=True)
1716
created_by = serializers.CharField(required=True, allow_null=True)
1817

18+
19+
class EntityVersionSerializer(serializers.Serializer): # pylint: disable=abstract-method
20+
"""
21+
Serializer for publishable entity versions.
22+
"""
23+
title = serializers.CharField(required=True)
24+
entity_key = serializers.CharField(required=True)
25+
created = serializers.DateTimeField(required=True)
26+
created_by = serializers.CharField(required=True, allow_null=True)
27+
28+
29+
class ComponentSerializer(EntitySerializer): # pylint: disable=abstract-method
30+
"""
31+
Serializer for components.
32+
Contains logic to convert entity_key to component_type and local_key.
33+
"""
34+
1935
def validate(self, attrs):
2036
"""
2137
Custom validation logic:
@@ -31,12 +47,76 @@ def validate(self, attrs):
3147
return attrs
3248

3349

34-
class ComponentVersionSerializer(serializers.Serializer): # pylint: disable=abstract-method
50+
class ComponentVersionSerializer(EntityVersionSerializer): # pylint: disable=abstract-method
3551
"""
3652
Serializer for component versions.
3753
"""
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)
4254
content_to_replace = serializers.DictField(child=serializers.CharField(), required=True)
55+
56+
57+
class ContainerSerializer(EntitySerializer): # pylint: disable=abstract-method
58+
"""
59+
Serializer for containers.
60+
"""
61+
container = serializers.DictField(required=True)
62+
63+
def validate_container(self, value):
64+
"""
65+
Custom validation logic for the container field.
66+
Ensures that the container dict has exactly one key which is one of
67+
"section", "subsection", or "unit" values.
68+
"""
69+
errors = []
70+
if not isinstance(value, dict) or len(value) != 1:
71+
errors.append("Container must be a dict with exactly one key.")
72+
if len(value) == 1: # Only check the key if there is exactly one
73+
container_type = list(value.keys())[0]
74+
if container_type not in ("section", "subsection", "unit"):
75+
errors.append(f"Invalid container value: {container_type}")
76+
if errors:
77+
raise serializers.ValidationError(errors)
78+
return value
79+
80+
def validate(self, attrs):
81+
"""
82+
Custom validation logic:
83+
parse the container dict to extract the container type.
84+
"""
85+
container = attrs["container"]
86+
container_type = list(container.keys())[0] # It is safe to do this after validate_container
87+
attrs["container_type"] = container_type
88+
attrs.pop("container") # Remove the container field after processing
89+
return attrs
90+
91+
92+
class ContainerVersionSerializer(EntityVersionSerializer): # pylint: disable=abstract-method
93+
"""
94+
Serializer for container versions.
95+
"""
96+
container = serializers.DictField(required=True)
97+
98+
def validate_container(self, value):
99+
"""
100+
Custom validation logic for the container field.
101+
Ensures that the container dict has exactly one key "children" which is a list of strings.
102+
"""
103+
errors = []
104+
if not isinstance(value, dict) or len(value) != 1:
105+
errors.append("Container must be a dict with exactly one key.")
106+
if "children" not in value:
107+
errors.append("Container must have a 'children' key.")
108+
if "children" in value and not isinstance(value["children"], list):
109+
errors.append("'children' must be a list.")
110+
if errors:
111+
raise serializers.ValidationError(errors)
112+
return value
113+
114+
def validate(self, attrs):
115+
"""
116+
Custom validation logic:
117+
parse the container dict to extract the children list.
118+
"""
119+
children = attrs["container"]["children"] # It is safe to do this after validate_container
120+
attrs["children"] = children
121+
attrs.pop("container") # Remove the container field after processing
122+
return attrs

openedx_learning/apps/authoring/backup_restore/toml.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def _get_toml_publishable_entity_table(
5959
[entity.published]
6060
version_num = 1
6161
62+
[entity.container.section]
63+
6264
Note: This function returns a tomlkit.items.Table, which represents
6365
a string-like TOML fragment rather than a complete TOML document.
6466
"""
@@ -83,6 +85,18 @@ def _get_toml_publishable_entity_table(
8385
else:
8486
published_table.add(tomlkit.comment("unpublished: no published_version_num"))
8587
entity_table.add("published", published_table)
88+
89+
if hasattr(entity, "container"):
90+
container_table = tomlkit.table()
91+
container_types = ["section", "subsection", "unit"]
92+
93+
for container_type in container_types:
94+
if hasattr(entity.container, container_type):
95+
container_table.add(container_type, tomlkit.table())
96+
break # stop after the first match
97+
98+
entity_table.add("container", container_table)
99+
86100
return entity_table
87101

88102

@@ -118,12 +132,14 @@ def toml_publishable_entity(
118132
119133
[version.container.unit]
120134
"""
135+
# Create the TOML representation for the entity itself
121136
entity_table = _get_toml_publishable_entity_table(entity, draft_version, published_version)
122137
doc = tomlkit.document()
123138
doc.add("entity", entity_table)
139+
140+
# Add versions as an array of tables (AoT)
124141
doc.add(tomlkit.nl())
125142
doc.add(tomlkit.comment("### Versions"))
126-
127143
for entity_version in versions_to_write:
128144
version = tomlkit.aot()
129145
version_table = toml_publishable_entity_version(entity_version)
@@ -164,9 +180,6 @@ def toml_publishable_entity_version(version: PublishableEntityVersion) -> tomlki
164180
children = publishing_api.get_container_children_entities_keys(version.containerversion)
165181
container_table.add("children", children)
166182

167-
unit_table = tomlkit.table()
168-
169-
container_table.add("unit", unit_table)
170183
version_table.add("container", container_table)
171184
return version_table # For use in AoT
172185

@@ -231,8 +244,4 @@ def parse_publishable_entity_toml(content: str) -> tuple[Dict[str, Any], list]:
231244
raise ValueError("Invalid publishable entity TOML: missing 'entity' section")
232245
if "version" not in pe_data:
233246
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")
238247
return pe_data["entity"], pe_data.get("version", [])

0 commit comments

Comments
 (0)