Skip to content
Merged
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
40 changes: 36 additions & 4 deletions src/openedx_content/applets/backup_restore/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from rest_framework import serializers

from ..components import api as components_api
from ..components.models import ComponentType


class LearningPackageSerializer(serializers.Serializer): # pylint: disable=abstract-method
Expand Down Expand Up @@ -59,24 +60,55 @@ 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.
Contains logic to convert entity_key to component_type and component_code.
"""

def validate(self, attrs):
"""
Custom validation logic:
parse the entity_key into (component_type, local_key).
parse the entity_key into (component_type, component_code).
"""
entity_key = attrs["key"]
try:
component_type_obj, local_key = components_api.get_or_create_component_type_by_entity_key(entity_key)
component_type_obj, component_code = _get_or_create_component_type_by_entity_key(entity_key)
attrs["component_type"] = component_type_obj
attrs["local_key"] = local_key
attrs["component_code"] = component_code
except ValueError as exc:
raise serializers.ValidationError({"key": str(exc)})
return attrs


def _get_or_create_component_type_by_entity_key(entity_key: str) -> tuple[ComponentType, str]:
"""
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}:{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.

Historical note: In Ulmo, this function was part of the public API. This was
inappropriate because the exact format of entity_keys is just a convention
rather than something API callers should count on. That said, it is safe to
assume that in all "v1" archives, the components' entity keys are safe to
parse into (namespace, type, code). So, we have moved this parsing logic
from the public API to just this internal halper function. Future devs,
please do not make new external guarantees about the format of entity keys
(aka entity_refs). A future "v2" backup-restore format will drop this
assumption of parse-ability..
"""
try:
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}:{component_code}'"
) from exc
return components_api.get_or_create_component_type(namespace, type_name), component_code


class ComponentVersionSerializer(EntityVersionSerializer): # pylint: disable=abstract-method
"""
Serializer for component versions.
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
38 changes: 20 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, code_field_check, 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,38 @@ 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",
),
Comment thread
kdmccormick marked this conversation as resolved.
code_field_check("component_code", name="oel_component_code_regex"),
]
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 +219,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