Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions olx_importer/management/commands/load_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ def import_block_type(self, block_type_name, now): # , publish_log_entry):

for xml_file_path in block_data_path.glob("*.xml"):
components_found += 1
local_key = xml_file_path.stem
component_code = xml_file_path.stem

# Do some basic parsing of the content to see if it's even well
# constructed enough to add (or whether we should skip/error on it).
Expand All @@ -155,7 +155,7 @@ def import_block_type(self, block_type_name, now): # , publish_log_entry):
_component, component_version = content_api.create_component_and_version(
self.learning_package.id,
component_type=block_type,
local_key=local_key,
component_code=component_code,
title=display_name,
created=now,
created_by=None,
Expand Down
48 changes: 38 additions & 10 deletions src/openedx_content/applets/backup_restore/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,21 +59,49 @@ class EntityVersionSerializer(serializers.Serializer): # pylint: disable=abstra
class ComponentSerializer(EntitySerializer): # pylint: disable=abstract-method
"""
Serializer for components.
Contains logic to convert entity_key to component_type and local_key.

Extracts component_type and component_code from the [entity.component]
section if present (archives created in Verawood or later). Falls back to
parsing the entity key for archives created in Ulmo.
"""

component = serializers.DictField(required=False)

def validate(self, attrs):
"""
Custom validation logic:
parse the entity_key into (component_type, local_key).
Custom validation logic: resolve component_type and component_code.

Archives created in Verawood or later supply an [entity.component]
section with ``component_type`` (e.g. "xblock.v1:problem") and
``component_code`` (e.g. "my_example"). Archives created in Ulmo only
have the entity ``key`` in the format
``"{namespace}:{type_name}:{component_code}"``, so we fall back to
parsing that for backwards compatibility.
"""
entity_key = attrs["key"]
try:
component_type_obj, local_key = components_api.get_or_create_component_type_by_entity_key(entity_key)
attrs["component_type"] = component_type_obj
attrs["local_key"] = local_key
except ValueError as exc:
raise serializers.ValidationError({"key": str(exc)})
component_section = attrs.pop("component", None)
if component_section:
# Verawood+ format: component_type and component_code are explicit.
component_type_str = component_section.get("component_type", "")
component_code = component_section.get("component_code", "")
try:
namespace, type_name = component_type_str.split(":", 1)
except ValueError as exc:
raise serializers.ValidationError(
{"component": f"Invalid component_type format: {component_type_str!r}. "
"Expected '{namespace}:{type_name}'."}
) from exc
component_type_obj = components_api.get_or_create_component_type(namespace, type_name)
else:
# Ulmo (legacy) format: parse the entity key.
entity_key = attrs["key"]
try:
component_type_obj, component_code = (
components_api.get_or_create_component_type_by_entity_key(entity_key)
)
except ValueError as exc:
raise serializers.ValidationError({"key": str(exc)})
attrs["component_type"] = component_type_obj
attrs["component_code"] = component_code
return attrs


Expand Down
9 changes: 9 additions & 0 deletions src/openedx_content/applets/backup_restore/toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ def _get_toml_publishable_entity_table(
published_table.add(tomlkit.comment("unpublished: no published_version_num"))
entity_table.add("published", published_table)

if hasattr(entity, "component"):
component = entity.component
component_table = tomlkit.table()
# Write component_type and component_code explicitly so that restore
# (Verawood and later) does not need to parse the entity key.
component_table.add("component_type", str(component.component_type))
component_table.add("component_code", component.component_code)
entity_table.add("component", component_table)

if hasattr(entity, "container"):
container_table = tomlkit.table()
container_types = ["section", "subsection", "unit"]
Expand Down
2 changes: 1 addition & 1 deletion src/openedx_content/applets/backup_restore/zipper.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ def create_zip(self, path: str) -> None:
# v1/
# static/

entity_filename = self.get_entity_toml_filename(entity.component.local_key)
entity_filename = self.get_entity_toml_filename(entity.component.component_code)

component_root_folder = (
# Example: "entities/xblock.v1/html/"
Expand Down
42 changes: 21 additions & 21 deletions src/openedx_content/applets/components/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@
"create_next_component_version",
"create_component_and_version",
"get_component",
"get_component_by_key",
"get_component_by_code",
"get_component_by_uuid",
"get_component_version_by_uuid",
"component_exists_by_key",
"component_exists_by_code",
"get_collection_components",
"get_components",
"create_component_version_media",
Expand Down Expand Up @@ -79,27 +79,27 @@ def get_or_create_component_type_by_entity_key(entity_key: str) -> tuple[Compone
Get or create a ComponentType based on a full entity key string.

The entity key is expected to be in the format
``"{namespace}:{type_name}:{local_key}"``. This function will parse out the
``namespace`` and ``type_name`` parts and use those to get or create the
``"{namespace}:{type_name}:{component_code}"``. This function will parse out
the ``namespace`` and ``type_name`` parts and use those to get or create the
ComponentType.

Raises ValueError if the entity_key is not in the expected format.
"""
try:
namespace, type_name, local_key = entity_key.split(':', 2)
namespace, type_name, component_code = entity_key.split(':', 2)
except ValueError as exc:
raise ValueError(
f"Invalid entity_key format: {entity_key!r}. "
"Expected format: '{namespace}:{type_name}:{local_key}'"
"Expected format: '{namespace}:{type_name}:{component_code}'"
) from exc
return get_or_create_component_type(namespace, type_name), local_key
return get_or_create_component_type(namespace, type_name), component_code


def create_component(
learning_package_id: LearningPackage.ID,
/,
component_type: ComponentType,
local_key: str,
component_code: str,
created: datetime,
created_by: int | None,
*,
Expand All @@ -108,7 +108,7 @@ def create_component(
"""
Create a new Component (an entity like a Problem or Video)
"""
key = f"{component_type.namespace}:{component_type.name}:{local_key}"
key = f"{component_type.namespace}:{component_type.name}:{component_code}"
with atomic():
publishable_entity = publishing_api.create_publishable_entity(
learning_package_id,
Expand All @@ -121,7 +121,7 @@ def create_component(
publishable_entity=publishable_entity,
learning_package_id=learning_package_id,
component_type=component_type,
local_key=local_key,
component_code=component_code,
)
return component

Expand Down Expand Up @@ -293,7 +293,7 @@ def create_component_and_version( # pylint: disable=too-many-positional-argumen
learning_package_id: LearningPackage.ID,
/,
component_type: ComponentType,
local_key: str,
component_code: str,
title: str,
created: datetime,
created_by: int | None = None,
Expand All @@ -307,7 +307,7 @@ def create_component_and_version( # pylint: disable=too-many-positional-argumen
component = create_component(
learning_package_id,
component_type,
local_key,
component_code,
created,
created_by,
can_stand_alone=can_stand_alone,
Expand All @@ -331,22 +331,22 @@ def get_component(component_id: Component.ID, /) -> Component:
return Component.with_publishing_relations.get(pk=component_id)


def get_component_by_key(
def get_component_by_code(
learning_package_id: LearningPackage.ID,
/,
namespace: str,
type_name: str,
local_key: str,
component_code: str,
) -> Component:
"""
Get a Component by its unique (namespace, type, local_key) tuple.
Get a Component by its unique (namespace, type, component_code) tuple.
"""
return Component.with_publishing_relations \
.get(
learning_package_id=learning_package_id,
component_type__namespace=namespace,
component_type__name=type_name,
local_key=local_key,
component_code=component_code,
)


Expand All @@ -366,12 +366,12 @@ def get_component_version_by_uuid(uuid: UUID) -> ComponentVersion:
)


def component_exists_by_key(
def component_exists_by_code(
learning_package_id: LearningPackage.ID,
/,
namespace: str,
type_name: str,
local_key: str
component_code: str
) -> bool:
"""
Return True/False for whether a Component exists.
Expand All @@ -384,7 +384,7 @@ def component_exists_by_key(
learning_package_id=learning_package_id,
component_type__namespace=namespace,
component_type__name=type_name,
local_key=local_key,
component_code=component_code,
)
return True
except Component.DoesNotExist:
Expand Down Expand Up @@ -423,12 +423,12 @@ def get_components( # pylint: disable=too-many-positional-arguments
if draft_title is not None:
qset = qset.filter(
Q(publishable_entity__draft__version__title__icontains=draft_title) |
Q(local_key__icontains=draft_title)
Q(component_code__icontains=draft_title)
)
if published_title is not None:
qset = qset.filter(
Q(publishable_entity__published__version__title__icontains=published_title) |
Q(local_key__icontains=published_title)
Q(component_code__icontains=published_title)
)

return qset
Expand Down
37 changes: 19 additions & 18 deletions src/openedx_content/applets/components/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from django.db import models
from typing_extensions import deprecated

from openedx_django_lib.fields import case_sensitive_char_field, key_field
from openedx_django_lib.fields import case_sensitive_char_field, code_field, key_field
from openedx_django_lib.managers import WithRelationsManager

from ..media.models import Media
Expand Down Expand Up @@ -119,9 +119,10 @@ class Component(PublishableEntityMixin):
State Consistency
-----------------

The ``key`` field on Component's ``publishable_entity`` is dervied from the
``component_type`` and ``local_key`` fields in this model. We don't support
changing the keys yet, but if we do, those values need to be kept in sync.
The ``key`` field on Component's ``publishable_entity`` is derived from the
``component_type`` and ``component_code`` fields in this model. We don't
support changing the keys yet, but if we do, those values need to be kept
in sync.

How build on this model
-----------------------
Expand Down Expand Up @@ -176,37 +177,37 @@ def pk(self):
# XBlock block_type, but we want it to be more flexible in the long term.
component_type = models.ForeignKey(ComponentType, on_delete=models.PROTECT)

# local_key is an identifier that is local to the learning_package and
# component_type. The publishable.key should be calculated as a
# combination of component_type and local_key.
local_key = key_field()
# component_code is an identifier that is local to the learning_package and
# component_type. The publishable.key is derived from component_type and
# component_code.
component_code = code_field()

class Meta:
constraints = [
# The combination of (component_type, local_key) is unique within
# a given LearningPackage. Note that this means it is possible to
# have two Components in the same LearningPackage to have the same
# local_key if the component_types are different. So for example,
# you could have a ProblemBlock and VideoBlock that both have the
# local_key "week_1".
# The combination of (component_type, component_code) is unique
# within a given LearningPackage. Note that this means it is
# possible to have two Components in the same LearningPackage with
# the same component_code if their component_types differ. For
# example, a ProblemBlock and VideoBlock could both have the
# component_code "week_1".
models.UniqueConstraint(
fields=[
"learning_package",
"component_type",
"local_key",
"component_code",
],
name="oel_component_uniq_lc_ct_lk",
),
]
indexes = [
# Global Component-Type/Local-Key Index:
# Global Component-Type/Component-Code Index:
# * Search by the different Components fields across all Learning
# Packages on the site. This would be a support-oriented tool
# from Django Admin.
models.Index(
fields=[
"component_type",
"local_key",
"component_code",
],
name="oel_component_idx_ct_lk",
),
Expand All @@ -217,7 +218,7 @@ class Meta:
verbose_name_plural = "Components"

def __str__(self) -> str:
return f"{self.component_type.namespace}:{self.component_type.name}:{self.local_key}"
return f"{self.component_type.namespace}:{self.component_type.name}:{self.component_code}"


class ComponentVersion(PublishableEntityVersionMixin):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ class VersioningHelper:
learning_package_id=learning_package.id,
namespace="xblock.v1",
type="problem",
local_key="monty_hall",
component_code="monty_hall",
title="Monty Hall Problem",
created=now,
created_by=None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from django.core.management.base import BaseCommand

from ...api import create_next_component_version, get_component_by_key, get_learning_package_by_key
from ...api import create_next_component_version, get_component_by_code, get_learning_package_by_key


class Command(BaseCommand):
Expand Down Expand Up @@ -59,22 +59,22 @@ def handle(self, *args, **options):

learning_package = get_learning_package_by_key(learning_package_key)
# Parse something like: "xblock.v1:problem:area_of_circle_1"
namespace, type_name, local_key = component_key.split(":", 2)
component = get_component_by_key(
learning_package.id, namespace, type_name, local_key
namespace, type_name, component_code = component_key.split(":", 2)
component = get_component_by_code(
learning_package.id, namespace, type_name, component_code
)

created = datetime.now(tz=timezone.utc)
local_keys_to_content_bytes = {}
media_path_to_content_bytes = {}

for file_mapping in file_mappings:
local_key, file_path = file_mapping.split(":", 1)
media_path, file_path = file_mapping.split(":", 1)

local_keys_to_content_bytes[local_key] = pathlib.Path(file_path).read_bytes() if file_path else None
media_path_to_content_bytes[media_path] = pathlib.Path(file_path).read_bytes() if file_path else None

next_version = create_next_component_version(
component.id,
media_to_replace=local_keys_to_content_bytes,
media_to_replace=media_path_to_content_bytes,
created=created,
)

Expand Down
Loading