diff --git a/.gitignore b/.gitignore index ea7591535..828320592 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.claude *.py[cod] __pycache__ .pytest_cache diff --git a/olx_importer/management/commands/load_components.py b/olx_importer/management/commands/load_components.py index 175175040..dae8ffeec 100644 --- a/olx_importer/management/commands/load_components.py +++ b/olx_importer/management/commands/load_components.py @@ -61,12 +61,12 @@ def init_known_types(self): def add_arguments(self, parser): parser.add_argument("course_data_path", type=pathlib.Path) - parser.add_argument("learning_package_key", type=str) + parser.add_argument("learning_package_ref", type=str) - def handle(self, course_data_path, learning_package_key, **options): + def handle(self, course_data_path, learning_package_ref, **options): self.course_data_path = course_data_path - self.learning_package_key = learning_package_key - self.load_course_data(learning_package_key) + self.learning_package_ref = learning_package_ref + self.load_course_data(learning_package_ref) def get_course_title(self): course_type_dir = self.course_data_path / "course" @@ -74,20 +74,20 @@ def get_course_title(self): course_root = ET.parse(course_xml_file).getroot() return course_root.attrib.get("display_name", "Unknown Course") - def load_course_data(self, learning_package_key): + def load_course_data(self, learning_package_ref): print(f"Importing course from: {self.course_data_path}") now = datetime.now(timezone.utc) title = self.get_course_title() - if content_api.learning_package_exists(learning_package_key): + if content_api.learning_package_exists(learning_package_ref): raise CommandError( - f"{learning_package_key} already exists. " + f"{learning_package_ref} already exists. " "This command currently only supports initial import." ) with transaction.atomic(): self.learning_package = content_api.create_learning_package( - learning_package_key, title, created=now, + learning_package_ref, title, created=now, ) for block_type in SUPPORTED_TYPES: self.import_block_type(block_type, now) #, publish_log_entry) @@ -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). @@ -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, @@ -173,7 +173,7 @@ def import_block_type(self, block_type_name, now): # , publish_log_entry): content_api.create_component_version_media( component_version, text_content.pk, - key="block.xml", + path="block.xml", ) # Cycle through static assets references and add those as well... diff --git a/src/openedx_content/applets/backup_restore/api.py b/src/openedx_content/applets/backup_restore/api.py index b4cda8828..1f768b0b0 100644 --- a/src/openedx_content/applets/backup_restore/api.py +++ b/src/openedx_content/applets/backup_restore/api.py @@ -5,26 +5,28 @@ from django.contrib.auth.models import User as UserType # pylint: disable=imported-auth-user -from ..publishing.api import get_learning_package_by_key +from ..publishing.api import get_learning_package_by_ref from .zipper import LearningPackageUnzipper, LearningPackageZipper -def create_zip_file(lp_key: str, path: str, user: UserType | None = None, origin_server: str | None = None) -> None: +def create_zip_file( + package_ref: str, path: str, user: UserType | None = None, origin_server: str | None = None +) -> None: """ Creates a dump zip file for the given learning package key at the given path. The zip file contains a TOML representation of the learning package and its contents. - Can throw a NotFoundError at get_learning_package_by_key + Can throw a NotFoundError at get_learning_package_by_ref """ - learning_package = get_learning_package_by_key(lp_key) + learning_package = get_learning_package_by_ref(package_ref) LearningPackageZipper(learning_package, user, origin_server).create_zip(path) -def load_learning_package(path: str, key: str | None = None, user: UserType | None = None) -> dict: +def load_learning_package(path: str, package_ref: str | None = None, user: UserType | None = None) -> dict: """ Loads a learning package from a zip file at the given path. Restores the learning package and its contents to the database. Returns a dictionary with the status of the operation and any errors encountered. """ with zipfile.ZipFile(path, "r") as zipf: - return LearningPackageUnzipper(zipf, key, user).load() + return LearningPackageUnzipper(zipf, package_ref, user).load() diff --git a/src/openedx_content/applets/backup_restore/serializers.py b/src/openedx_content/applets/backup_restore/serializers.py index d8f7a5c15..0a3e80089 100644 --- a/src/openedx_content/applets/backup_restore/serializers.py +++ b/src/openedx_content/applets/backup_restore/serializers.py @@ -12,15 +12,30 @@ class LearningPackageSerializer(serializers.Serializer): # pylint: disable=abst """ Serializer for learning packages. + Archives created in Verawood or later write ``package_ref``. Archives + created in Ulmo write ``key``. Both are accepted; ``package_ref`` takes + precedence. + Note: - The `key` field is serialized, but it is generally not trustworthy for restoration. - During restore, a new key may be generated or overridden. + The ref/key field is serialized but is generally not trustworthy for + restoration. During restore, a new ref may be generated or overridden. """ + title = serializers.CharField(required=True) - key = serializers.CharField(required=True) + package_ref = serializers.CharField(required=False) + key = serializers.CharField(required=False) description = serializers.CharField(required=True, allow_blank=True) created = serializers.DateTimeField(required=True, default_timezone=timezone.utc) + def validate(self, attrs): + package_ref = attrs.pop("package_ref", None) + legacy_key = attrs.pop("key", None) + ref = package_ref or legacy_key + if not ref: + raise serializers.ValidationError("Either 'package_ref' or 'key' is required.") + attrs["package_ref"] = ref # Normalise to 'package_ref' for create_learning_package. + return attrs + class LearningPackageMetadataSerializer(serializers.Serializer): # pylint: disable=abstract-method """ @@ -40,18 +55,33 @@ class LearningPackageMetadataSerializer(serializers.Serializer): # pylint: disa class EntitySerializer(serializers.Serializer): # pylint: disable=abstract-method """ Serializer for publishable entities. + + Archives created in Verawood or later write ``entity_ref``. Archives + created in Ulmo use ``key``. Both are accepted; ``entity_ref`` takes + precedence. """ + can_stand_alone = serializers.BooleanField(required=True) - key = serializers.CharField(required=True) + entity_ref = serializers.CharField(required=False) + key = serializers.CharField(required=False) created = serializers.DateTimeField(required=True, default_timezone=timezone.utc) + def validate(self, attrs): + entity_ref = attrs.pop("entity_ref", None) + legacy_key = attrs.pop("key", None) + ref = entity_ref or legacy_key + if not ref: + raise serializers.ValidationError("Either 'entity_ref' or 'key' is required.") + attrs["entity_ref"] = ref + return attrs + class EntityVersionSerializer(serializers.Serializer): # pylint: disable=abstract-method """ Serializer for publishable entity versions. """ title = serializers.CharField(required=True) - entity_key = serializers.CharField(required=True) + entity_ref = serializers.CharField(required=True) created = serializers.DateTimeField(required=True, default_timezone=timezone.utc) version_num = serializers.IntegerField(required=True) @@ -59,21 +89,57 @@ 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)}) + super().validate(attrs) + 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_ref (which ws normalized + # from "key" in super.validate()) assuming the format: + # (namespace, type_name, component_code). This parsing is + # intentionally only here — entity_ref must not be parsed anywhere + # else in the codebase. Verawood+ archives may not follow this + # convention. + entity_ref = attrs["entity_ref"] + try: + namespace, type_name, component_code = entity_ref.split(":", 2) + except ValueError as exc: + raise serializers.ValidationError( + {"key": f"Invalid entity key format: {entity_ref!r}. " + "Expected '{namespace}:{type_name}:{component_code}'."} + ) from exc + component_type_obj = components_api.get_or_create_component_type(namespace, type_name) + attrs["component_type"] = component_type_obj + attrs["component_code"] = component_code return attrs @@ -86,35 +152,46 @@ class ComponentVersionSerializer(EntityVersionSerializer): # pylint: disable=ab class ContainerSerializer(EntitySerializer): # pylint: disable=abstract-method """ Serializer for containers. + + Extracts container_code from the [entity.container] section. + Archives created in Verawood or later include an explicit + ``container_code`` field. Archives created in Ulmo do not, so we + fall back to using the entity key as the container_code. """ + container = serializers.DictField(required=True) def validate_container(self, value): """ Custom validation logic for the container field. - Ensures that the container dict has exactly one key which is one of - "section", "subsection", or "unit" values. + Ensures that the container dict has exactly one type key ("section", + "subsection", or "unit"), optionally alongside "container_code". """ errors = [] - if not isinstance(value, dict) or len(value) != 1: - errors.append("Container must be a dict with exactly one key.") - if len(value) == 1: # Only check the key if there is exactly one - container_type = list(value.keys())[0] - if container_type not in ("section", "subsection", "unit"): - errors.append(f"Invalid container value: {container_type}") + type_keys = [k for k in value if k in ("section", "subsection", "unit")] + if len(type_keys) != 1: + errors.append( + "Container must have exactly one type key: 'section', 'subsection', or 'unit'." + ) if errors: raise serializers.ValidationError(errors) return value def validate(self, attrs): """ - Custom validation logic: - parse the container dict to extract the container type. + Custom validation logic: extract container_type and container_code. + + Archives created in Verawood or later supply an explicit + ``container_code`` field inside [entity.container]. Archives created + in Ulmo do not, so we fall back to using the entity key. """ - container = attrs["container"] - container_type = list(container.keys())[0] # It is safe to do this after validate_container + super().validate(attrs) + container = attrs.pop("container") + # It is safe to do this after validate_container + container_type = next(k for k in container if k in ("section", "subsection", "unit")) attrs["container_type"] = container_type - attrs.pop("container") # Remove the container field after processing + # Verawood+: container_code is explicit. Ulmo: fall back to entity_ref. + attrs["container_code"] = container.get("container_code") or attrs["entity_ref"] return attrs @@ -156,6 +233,8 @@ class CollectionSerializer(serializers.Serializer): # pylint: disable=abstract- Serializer for collections. """ title = serializers.CharField(required=True) + # Note: the model field is now Collection.collection_code, but the archive + # format still uses "key". A future v2 format may align the name. key = serializers.CharField(required=True) description = serializers.CharField(required=True, allow_blank=True) entities = serializers.ListField( diff --git a/src/openedx_content/applets/backup_restore/toml.py b/src/openedx_content/applets/backup_restore/toml.py index d39861803..c38d856c1 100644 --- a/src/openedx_content/applets/backup_restore/toml.py +++ b/src/openedx_content/applets/backup_restore/toml.py @@ -43,7 +43,9 @@ def toml_learning_package( # Learning package main info section = tomlkit.table() section.add("title", learning_package.title) - section.add("key", learning_package.key) + # Write package_ref (Verawood+) and key (Ulmo back-compat). + section.add("package_ref", learning_package.package_ref) + section.add("key", learning_package.package_ref) section.add("description", learning_package.description) section.add("created", learning_package.created) section.add("updated", learning_package.updated) @@ -89,8 +91,10 @@ def _get_toml_publishable_entity_table( """ entity_table = tomlkit.table() entity_table.add("can_stand_alone", entity.can_stand_alone) - # Add key since the toml filename doesn't show the real key - entity_table.add("key", entity.key) + # Write entity_ref (Verawood+) and key (Ulmo back-compat) so that older + # restore code can still read archives produced after this rename. + entity_table.add("entity_ref", entity.entity_ref) + entity_table.add("key", entity.entity_ref) entity_table.add("created", entity.created) if not include_versions: @@ -108,12 +112,25 @@ 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 = entity.container container_table = tomlkit.table() + # Write container_code explicitly so that restore (Verawood and later) + # does not need to parse the entity key. + container_table.add("container_code", container.container_code) container_types = ["section", "subsection", "unit"] for container_type in container_types: - if hasattr(entity.container, container_type): + if hasattr(container, container_type): container_table.add(container_type, tomlkit.table()) break # stop after the first match @@ -191,13 +208,13 @@ def toml_publishable_entity_version(version: PublishableEntityVersion) -> tomlki if hasattr(version, 'containerversion'): # If the version has a container version, add its children container_table = tomlkit.table() - children = containers_api.get_container_children_entities_keys(version.containerversion) + children = containers_api.get_container_children_entity_refs(version.containerversion) container_table.add("children", children) version_table.add("container", container_table) return version_table -def toml_collection(collection: Collection, entity_keys: list[str]) -> str: +def toml_collection(collection: Collection, entity_refs: list[str]) -> str: """ Create a TOML representation of a collection. @@ -215,12 +232,14 @@ def toml_collection(collection: Collection, entity_keys: list[str]) -> str: doc = tomlkit.document() entities_array = tomlkit.array() - entities_array.extend(entity_keys) + entities_array.extend(entity_refs) entities_array.multiline(True) collection_table = tomlkit.table() collection_table.add("title", collection.title) - collection_table.add("key", collection.key) + # Note: the model field is now Collection.collection_code, but the archive + # format still uses "key". A future v2 format may align the name. + collection_table.add("key", collection.collection_code) collection_table.add("description", collection.description) collection_table.add("created", collection.created) collection_table.add("entities", entities_array) diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 5f7b2457e..f10ae33d7 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -202,7 +202,7 @@ def get_publishable_entities(self) -> QuerySet[PublishableEntity]: to_attr="prefetched_media", ), ) - .order_by("key") + .order_by("entity_ref") ) def get_collections(self) -> QuerySet[Collection]: @@ -238,25 +238,25 @@ def get_versions_to_write( versions_to_write.append(published_version) return versions_to_write, draft_version, published_version - def get_entity_toml_filename(self, entity_key: str) -> str: + def get_entity_toml_filename(self, entity_ref: str) -> str: """ Generate a unique TOML filename for a publishable entity. Ensures that the filename is unique within the zip file. Behavior: - - If the slugified key has not been used yet, use it as the filename. + - If the slugified ref has not been used yet, use it as the filename. - If it has been used, append a short hash to ensure uniqueness. Args: - entity_key (str): The key of the publishable entity. + entity_ref (str): The entity_ref of the publishable entity. Returns: str: A unique TOML filename for the entity. """ - slugify_name = slugify(entity_key, allow_unicode=True) + slugify_name = slugify(entity_ref, allow_unicode=True) if slugify_name in self.entities_filenames_already_created: - filename = slugify_hashed_filename(entity_key) + filename = slugify_hashed_filename(entity_ref) else: filename = slugify_name @@ -316,7 +316,7 @@ def create_zip(self, path: str) -> None: ) if hasattr(entity, 'container'): - entity_filename = self.get_entity_toml_filename(entity.key) + entity_filename = self.get_entity_toml_filename(entity.entity_ref) entity_toml_filename = f"{entity_filename}.toml" entity_toml_path = entities_folder / entity_toml_filename self.add_file_to_zip(zipf, entity_toml_path, entity_toml_content, timestamp=latest_modified) @@ -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/" @@ -381,9 +381,8 @@ def create_zip(self, path: str) -> None: for component_version_media in prefetched_media: media: Media = component_version_media.media - # Important: The component_version_media.key contains implicitly - # the file name and the file extension - file_path = version_folder / component_version_media.key + # component_version_media.path is the file name and extension + file_path = version_folder / component_version_media.path if media.has_file and media.path: # If has_file, we pull it from the file system @@ -401,13 +400,13 @@ def create_zip(self, path: str) -> None: collections = self.get_collections() for collection in collections: - collection_hash_slug = self.get_entity_toml_filename(collection.key) + collection_hash_slug = self.get_entity_toml_filename(collection.collection_code) collection_toml_file_path = collections_folder / f"{collection_hash_slug}.toml" - entity_keys_related = collection.entities.order_by("key").values_list("key", flat=True) + entity_refs_related = collection.entities.order_by("entity_ref").values_list("entity_ref", flat=True) self.add_file_to_zip( zipf, collection_toml_file_path, - toml_collection(collection, list(entity_keys_related)), + toml_collection(collection, list(entity_refs_related)), timestamp=collection.modified, ) @@ -418,10 +417,10 @@ class RestoreLearningPackageData: Data about the restored learning package. """ id: int # The ID of the restored learning package - key: str # The key of the restored learning package (may be different if staged) - archive_lp_key: str # The original key from the archive - archive_org_key: str # The original organization key from the archive - archive_slug: str # The original slug from the archive + package_ref: str # The package_ref of the restored learning package (may be different if staged) + archive_package_ref: str # The original package_ref from the archive + archive_org_code: str | None # The org code parsed from archive_package_ref, or None if unparseable + archive_package_code: str | None # The package code parsed from archive_package_ref, or None if unparseable title: str num_containers: int num_sections: int @@ -454,35 +453,43 @@ class RestoreResult: backup_metadata: BackupMetadata | None = None -def unpack_lp_key(lp_key: str) -> tuple[str, str]: +def unpack_package_ref(package_ref: str) -> tuple[str | None, str | None]: """ - Unpack a learning package key into its components. + Try to parse org_code and package_code from a package_ref. + + By convention, package_refs take the form ``"{prefix}:{org_code}:{package_code}"``, + but this is only a convention — package_ref is opaque and the parse may fail. + Returns ``(None, None)`` if the ref does not match the expected format. """ - parts = lp_key.split(":") + parts = package_ref.split(":") if len(parts) < 3: - raise ValueError(f"Invalid learning package key: {lp_key}") - _, org_key, lp_slug = parts[:3] - return org_key, lp_slug + return None, None + _, org_code, package_code = parts[:3] + return org_code, package_code -def generate_staged_lp_key(archive_lp_key: str, user: UserType) -> str: +def generate_staged_package_ref(archive_package_ref: str, user: UserType) -> str: """ - Generate a staged learning package key based on the given base key. + Generate a staged learning package ref based on the archive's package_ref. Arguments: - archive_lp_key (str): The original learning package key from the archive. + archive_package_ref (str): The original package_ref from the archive. user (UserType | None): The user performing the restore operation. Example: Input: "lib:WGU:LIB_C001" Output: "lp-restore:dave:WGU:LIB_C001:1728575321" - The timestamp at the end ensures the key is unique. + The timestamp at the end ensures the ref is unique. Falls back to using + the full archive_package_ref when the conventional format is not recognised. """ username = user.username - org_key, lp_slug = unpack_lp_key(archive_lp_key) + org_code, package_code = unpack_package_ref(archive_package_ref) timestamp = int(time.time() * 1000) # Current time in milliseconds - return f"lp-restore:{username}:{org_key}:{lp_slug}:{timestamp}" + if org_code and package_code: + return f"lp-restore:{username}:{org_code}:{package_code}:{timestamp}" + # Fallback for non-conventional package_refs + return f"lp-restore:{username}:{archive_package_ref}:{timestamp}" class LearningPackageUnzipper: @@ -507,21 +514,21 @@ class LearningPackageUnzipper: result = unzipper.load() """ - def __init__(self, zipf: zipfile.ZipFile, key: str | None = None, user: UserType | None = None): + def __init__(self, zipf: zipfile.ZipFile, package_ref: str | None = None, user: UserType | None = None): self.zipf = zipf self.user = user self.user_id = getattr(self.user, "id", None) - self.lp_key = key # If provided, use this key for the restored learning package + self.package_ref = package_ref # If provided, use this package_ref for the restored learning package self.learning_package_id: LearningPackage.ID | None = None # Will be set upon restoration self.utc_now: datetime = datetime.now(timezone.utc) self.component_types_cache: dict[tuple[str, str], ComponentType] = {} self.errors: list[dict[str, Any]] = [] # Maps for resolving relationships - self.components_map_by_key: dict[str, Any] = {} - self.units_map_by_key: dict[str, Any] = {} - self.subsections_map_by_key: dict[str, Any] = {} - self.sections_map_by_key: dict[str, Any] = {} - self.all_publishable_entities_keys: set[str] = set() + self.components_map_by_ref: dict[str, Any] = {} + self.units_map_by_ref: dict[str, Any] = {} + self.subsections_map_by_ref: dict[str, Any] = {} + self.sections_map_by_ref: dict[str, Any] = {} + self.all_publishable_entity_refs: set[str] = set() self.all_published_entities_versions: set[tuple[str, int]] = set() # To track published entity versions # -------------------------- @@ -574,7 +581,7 @@ def load(self) -> dict[str, Any]: # Step 3.2: Save everything to the DB # All validations passed, we can proceed to save everything # Save the learning package first to get its ID - archive_lp_key = learning_package_validated["key"] + archive_package_ref = learning_package_validated["package_ref"] learning_package = self._save( learning_package_validated, components_validated, @@ -588,16 +595,16 @@ def load(self) -> dict[str, Any]: for container_type in ["section", "subsection", "unit"] ) - org_key, lp_slug = unpack_lp_key(archive_lp_key) + org_code, package_code = unpack_package_ref(archive_package_ref) result = RestoreResult( status="success", log_file_error=None, lp_restored_data=RestoreLearningPackageData( id=learning_package.id, - key=learning_package.key, - archive_lp_key=archive_lp_key, # The original key from the backup archive - archive_org_key=org_key, # The original organization key from the backup archive - archive_slug=lp_slug, # The original slug from the backup archive + package_ref=learning_package.package_ref, + archive_package_ref=archive_package_ref, + archive_org_code=org_code, + archive_package_code=package_code, title=learning_package.title, num_containers=num_containers, num_sections=len(containers_validated.get("section", [])), @@ -678,7 +685,7 @@ def _extract_entities( continue entity_data = serializer.validated_data - self.all_publishable_entities_keys.add(entity_data["key"]) + self.all_publishable_entity_refs.add(entity_data["entity_ref"]) entity_type = entity_data.pop("container_type", "components") results[entity_type].append(entity_data) @@ -716,11 +723,11 @@ def _extract_collections( continue collection_validated = serializer.validated_data entities_list = collection_validated["entities"] - for entity_key in entities_list: - if entity_key not in self.all_publishable_entities_keys: + for entity_ref in entities_list: + if entity_ref not in self.all_publishable_entity_refs: self.errors.append({ "file": file, - "errors": f"Entity key {entity_key} not found for collection {collection_validated.get('key')}" + "errors": f"Entity ref {entity_ref} not found for collection {collection_validated.get('key')}" }) results["collections"].append(collection_validated) @@ -743,16 +750,16 @@ def _save( # Important: If not using a specific LP key, generate a temporary one # We cannot use the original key because it may generate security issues - if not self.lp_key: - # Generate a tmp key for the staged learning package + if not self.package_ref: + # Generate a tmp ref for the staged learning package if not self.user: - raise ValueError("User is required to create lp_key") - learning_package["key"] = generate_staged_lp_key( - archive_lp_key=learning_package["key"], + raise ValueError("User is required to generate a staged package_ref") + learning_package["package_ref"] = generate_staged_package_ref( + archive_package_ref=learning_package["package_ref"], user=self.user ) else: - learning_package["key"] = self.lp_key + learning_package["package_ref"] = self.package_ref learning_package_obj = publishing_api.create_learning_package(**learning_package) self.learning_package_id = learning_package_obj.id @@ -774,31 +781,38 @@ def _save_collections(self, learning_package, collections): """Save collections and their entities.""" for valid_collection in collections.get("collections", []): entities = valid_collection.pop("entities", []) + # The archive format uses "key"; the API now expects "collection_code". + valid_collection["collection_code"] = valid_collection.pop("key") collection = collections_api.create_collection( learning_package.id, created_by=self.user_id, **valid_collection ) collection = collections_api.add_to_collection( learning_package_id=learning_package.id, - key=collection.key, - entities_qset=publishing_api.get_publishable_entities(learning_package.id).filter(key__in=entities) + collection_code=collection.collection_code, + entities_qset=publishing_api.get_publishable_entities(learning_package.id).filter( + entity_ref__in=entities + ) ) def _save_components(self, learning_package, components, component_static_files): """Save components and published component versions.""" for valid_component in components.get("components", []): - entity_key = valid_component.pop("key") + entity_ref = valid_component.pop("entity_ref") component = components_api.create_component(learning_package.id, created_by=self.user_id, **valid_component) - self.components_map_by_key[entity_key] = component + self.components_map_by_ref[entity_ref] = component for valid_published in components.get("components_published", []): - entity_key = valid_published.pop("entity_key") + entity_ref = valid_published.pop("entity_ref") version_num = valid_published["version_num"] # Should exist, validated earlier - media_to_replace = self._resolve_static_files(version_num, entity_key, component_static_files) + component = self.components_map_by_ref[entity_ref] + media_to_replace = self._resolve_static_files( + version_num, entity_ref, component.component_type, component_static_files + ) self.all_published_entities_versions.add( - (entity_key, version_num) + (entity_ref, version_num) ) # Track published version components_api.create_next_component_version( - self.components_map_by_key[entity_key].publishable_entity.id, + component.publishable_entity.id, media_to_replace=media_to_replace, force_version_num=valid_published.pop("version_num", None), created_by=self.user_id, @@ -817,23 +831,23 @@ def _save_container( """Internal logic for _save_units, _save_subsections, and _save_sections""" type_code = container_cls.type_code # e.g. "unit" for data in containers.get(type_code, []): - entity_key = data.get("key") + entity_ref = data.pop("entity_ref") container = containers_api.create_container( learning_package.id, **data, # should this be allowed to override any of the following fields? created_by=self.user_id, container_cls=container_cls, ) - container_map[entity_key] = container # e.g. `self.units_map_by_key[entity_key] = unit` + container_map[entity_ref] = container # e.g. `self.units_map_by_ref[entity_ref] = unit` for valid_published in containers.get(f"{type_code}_published", []): - entity_key = valid_published.pop("entity_key") + entity_ref = valid_published.pop("entity_ref") children = self._resolve_children(valid_published, children_map) self.all_published_entities_versions.add( - (entity_key, valid_published.get('version_num')) + (entity_ref, valid_published.get('version_num')) ) # Track published version containers_api.create_next_container_version( - container_map[entity_key], + container_map[entity_ref], **valid_published, # should this be allowed to override any of the following fields? force_version_num=valid_published.pop("version_num", None), entities=children, @@ -846,8 +860,8 @@ def _save_units(self, learning_package, containers): learning_package, containers, container_cls=Unit, - container_map=self.units_map_by_key, - children_map=self.components_map_by_key, + container_map=self.units_map_by_ref, + children_map=self.components_map_by_ref, ) def _save_subsections(self, learning_package, containers): @@ -856,8 +870,8 @@ def _save_subsections(self, learning_package, containers): learning_package, containers, container_cls=Subsection, - container_map=self.subsections_map_by_key, - children_map=self.units_map_by_key, + container_map=self.subsections_map_by_ref, + children_map=self.units_map_by_ref, ) def _save_sections(self, learning_package, containers): @@ -866,20 +880,23 @@ def _save_sections(self, learning_package, containers): learning_package, containers, container_cls=Section, - container_map=self.sections_map_by_key, - children_map=self.subsections_map_by_key, + container_map=self.sections_map_by_ref, + children_map=self.subsections_map_by_ref, ) def _save_draft_versions(self, components, containers, component_static_files): """Save draft versions for all entity types.""" for valid_draft in components.get("components_drafts", []): - entity_key = valid_draft.pop("entity_key") + entity_ref = valid_draft.pop("entity_ref") version_num = valid_draft["version_num"] # Should exist, validated earlier - if self._is_version_already_exists(entity_key, version_num): + if self._is_version_already_exists(entity_ref, version_num): continue - media_to_replace = self._resolve_static_files(version_num, entity_key, component_static_files) + component = self.components_map_by_ref[entity_ref] + media_to_replace = self._resolve_static_files( + version_num, entity_ref, component.component_type, component_static_files + ) components_api.create_next_component_version( - self.components_map_by_key[entity_key].publishable_entity.id, + component.publishable_entity.id, media_to_replace=media_to_replace, force_version_num=valid_draft.pop("version_num", None), # Drafts can diverge from published, so we allow ignoring previous media @@ -895,23 +912,23 @@ def _process_draft_containers( children_map: dict, ): for valid_draft in containers.get(f"{container_cls.type_code}_drafts", []): - entity_key = valid_draft.pop("entity_key") + entity_ref = valid_draft.pop("entity_ref") version_num = valid_draft["version_num"] # Should exist, validated earlier - if self._is_version_already_exists(entity_key, version_num): + if self._is_version_already_exists(entity_ref, version_num): continue children = self._resolve_children(valid_draft, children_map) del valid_draft["version_num"] containers_api.create_next_container_version( - container_map[entity_key], + container_map[entity_ref], **valid_draft, # should this be allowed to override any of the following fields? entities=children, force_version_num=version_num, created_by=self.user_id, ) - _process_draft_containers(Unit, self.units_map_by_key, children_map=self.components_map_by_key) - _process_draft_containers(Subsection, self.subsections_map_by_key, children_map=self.units_map_by_key) - _process_draft_containers(Section, self.sections_map_by_key, children_map=self.subsections_map_by_key) + _process_draft_containers(Unit, self.units_map_by_ref, children_map=self.components_map_by_ref) + _process_draft_containers(Subsection, self.subsections_map_by_ref, children_map=self.units_map_by_ref) + _process_draft_containers(Section, self.sections_map_by_ref, children_map=self.subsections_map_by_ref) # -------------------------- # Utilities @@ -933,9 +950,9 @@ def _write_errors(self) -> StringIO | None: return None return StringIO(content) - def _is_version_already_exists(self, entity_key: str, version_num: int) -> bool: + def _is_version_already_exists(self, entity_ref: str, version_num: int) -> bool: """ - Check if a version already exists for a given entity key and version number. + Check if a version already exists for a given entity_ref and version number. Note: Skip creating draft if this version is already published @@ -944,20 +961,21 @@ def _is_version_already_exists(self, entity_key: str, version_num: int) -> bool: Otherwise, we will raise an IntegrityError on PublishableEntityVersion due to unique constraints between publishable_entity and version_num. """ - identifier = (entity_key, version_num) + identifier = (entity_ref, version_num) return identifier in self.all_published_entities_versions def _resolve_static_files( self, num_version: int, - entity_key: str, + entity_ref: str, + component_type, static_files_map: dict[str, List[str]] ) -> dict[str, bytes | int]: """Resolve static file paths into their binary media content.""" resolved_files: dict[str, bytes | int] = {} - static_file_key = f"{entity_key}:v{num_version}" # e.g., "xblock.v1:html:my_component_123456:v1" - block_type = entity_key.split(":")[1] # e.g., "html" + static_file_key = f"{entity_ref}:v{num_version}" # e.g., "xblock.v1:html:my_component_123456:v1" + block_type = component_type.name # e.g., "html" static_files = static_files_map.get(static_file_key, []) for static_file in static_files: local_key = static_file.split(f"v{num_version}/")[-1] @@ -980,9 +998,9 @@ def _resolve_static_files( return resolved_files def _resolve_children(self, entity_data: dict[str, Any], lookup_map: dict[str, Any]) -> list[Any]: - """Resolve child entity keys into model instances.""" - children_keys = entity_data.pop("children", []) - return [lookup_map[key] for key in children_keys if key in lookup_map] + """Resolve child entity refs into model instances.""" + children_refs = entity_data.pop("children", []) + return [lookup_map[ref] for ref in children_refs if ref in lookup_map] def _load_entity_data( self, entity_file: str @@ -1002,7 +1020,7 @@ def _validate_versions(self, entity_data, draft, published, serializer_cls, *, f continue serializer = serializer_cls( data={ - "entity_key": entity_data["key"], + "entity_ref": entity_data["entity_ref"], "created": self.utc_now, "created_by": None, **version diff --git a/src/openedx_content/applets/collections/admin.py b/src/openedx_content/applets/collections/admin.py index eb0685a27..41db9e082 100644 --- a/src/openedx_content/applets/collections/admin.py +++ b/src/openedx_content/applets/collections/admin.py @@ -13,14 +13,14 @@ class CollectionAdmin(admin.ModelAdmin): Allows users to easily disable/enable (aka soft delete and restore) or bulk delete Collections. """ - readonly_fields = ["key", "learning_package"] + readonly_fields = ["collection_code", "learning_package"] list_filter = ["enabled"] - list_display = ["key", "title", "enabled", "modified"] + list_display = ["collection_code", "title", "enabled", "modified"] fieldsets = [ ( "", { - "fields": ["key", "learning_package"], + "fields": ["collection_code", "learning_package"], } ), ( diff --git a/src/openedx_content/applets/collections/api.py b/src/openedx_content/applets/collections/api.py index b57b3bea8..6abe5e2b7 100644 --- a/src/openedx_content/applets/collections/api.py +++ b/src/openedx_content/applets/collections/api.py @@ -34,7 +34,7 @@ def create_collection( learning_package_id: LearningPackage.ID, - key: str, + collection_code: str, *, title: str, created_by: int | None, @@ -44,35 +44,37 @@ def create_collection( """ Create a new Collection """ - collection = Collection.objects.create( + collection = Collection( learning_package_id=learning_package_id, - key=key, + collection_code=collection_code, title=title, created_by_id=created_by, description=description, enabled=enabled, ) + collection.full_clean() + collection.save() return collection -def get_collection(learning_package_id: LearningPackage.ID, collection_key: str) -> Collection: +def get_collection(learning_package_id: LearningPackage.ID, collection_code: str) -> Collection: """ Get a Collection by ID """ - return Collection.objects.get_by_key(learning_package_id, collection_key) + return Collection.objects.get_by_code(learning_package_id, collection_code) def update_collection( learning_package_id: LearningPackage.ID, - key: str, + collection_code: str, *, title: str | None = None, description: str | None = None, ) -> Collection: """ - Update a Collection identified by the learning_package_id + key. + Update a Collection identified by the learning_package_id + collection_code. """ - collection = get_collection(learning_package_id, key) + collection = get_collection(learning_package_id, collection_code) # If no changes were requested, there's nothing to update, so just return # the Collection as-is @@ -90,17 +92,17 @@ def update_collection( def delete_collection( learning_package_id: LearningPackage.ID, - key: str, + collection_code: str, *, hard_delete=False, ) -> Collection: """ - Disables or deletes a collection identified by the given learning_package + key. + Disables or deletes a collection identified by the given learning_package + collection_code. By default (hard_delete=False), the collection is "soft deleted", i.e disabled. Soft-deleted collections can be re-enabled using restore_collection. """ - collection = get_collection(learning_package_id, key) + collection = get_collection(learning_package_id, collection_code) if hard_delete: collection.delete() @@ -112,12 +114,12 @@ def delete_collection( def restore_collection( learning_package_id: LearningPackage.ID, - key: str, + collection_code: str, ) -> Collection: """ Undo a "soft delete" by re-enabling a Collection. """ - collection = get_collection(learning_package_id, key) + collection = get_collection(learning_package_id, collection_code) collection.enabled = True collection.save() @@ -126,7 +128,7 @@ def restore_collection( def add_to_collection( learning_package_id: LearningPackage.ID, - key: str, + collection_code: str, entities_qset: QuerySet[PublishableEntity], created_by: int | None = None, ) -> Collection: @@ -146,10 +148,10 @@ def add_to_collection( if invalid_entity: raise ValidationError( f"Cannot add entity {invalid_entity.id} in learning package {invalid_entity.learning_package_id} " - f"to collection {key} in learning package {learning_package_id}." + f"to collection {collection_code} in learning package {learning_package_id}." ) - collection = get_collection(learning_package_id, key) + collection = get_collection(learning_package_id, collection_code) collection.entities.add( *entities_qset.all(), through_defaults={"created_by_id": created_by}, @@ -162,7 +164,7 @@ def add_to_collection( def remove_from_collection( learning_package_id: LearningPackage.ID, - key: str, + collection_code: str, entities_qset: QuerySet[PublishableEntity], ) -> Collection: """ @@ -174,7 +176,7 @@ def remove_from_collection( Returns the updated Collection. """ - collection = get_collection(learning_package_id, key) + collection = get_collection(learning_package_id, collection_code) collection.entities.remove(*entities_qset.all()) collection.modified = datetime.now(tz=timezone.utc) @@ -183,22 +185,22 @@ def remove_from_collection( return collection -def get_entity_collections(learning_package_id: LearningPackage.ID, entity_key: str) -> QuerySet[Collection]: +def get_entity_collections(learning_package_id: LearningPackage.ID, entity_ref: str) -> QuerySet[Collection]: """ Get all collections in the given learning package which contain this entity. Only enabled collections are returned. """ - entity = publishing_api.get_publishable_entity_by_key( + entity = publishing_api.get_publishable_entity_by_ref( learning_package_id, - key=entity_key, + entity_ref=entity_ref, ) return entity.collections.filter(enabled=True).order_by("pk") def get_collection_entities( learning_package_id: LearningPackage.ID, - collection_key: str, + collection_code: str, ) -> QuerySet[PublishableEntity]: """ Returns a QuerySet of PublishableEntities in a Collection. @@ -207,7 +209,7 @@ def get_collection_entities( """ return PublishableEntity.objects.filter( learning_package_id=learning_package_id, - collections__key=collection_key, + collections__collection_code=collection_code, ).order_by("pk") diff --git a/src/openedx_content/applets/collections/models.py b/src/openedx_content/applets/collections/models.py index 2f315b247..a1c0fa4a4 100644 --- a/src/openedx_content/applets/collections/models.py +++ b/src/openedx_content/applets/collections/models.py @@ -70,7 +70,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from openedx_django_lib.fields import MultiCollationTextField, case_insensitive_char_field, key_field +from openedx_django_lib.fields import MultiCollationTextField, case_insensitive_char_field, code_field, code_field_check from openedx_django_lib.validators import validate_utc_datetime from ..publishing.models import LearningPackage, PublishableEntity @@ -85,12 +85,12 @@ class CollectionManager(models.Manager): """ Custom manager for Collection class. """ - def get_by_key(self, learning_package_id: int, key: str): + def get_by_code(self, learning_package_id: int, collection_code: str): """ - Get the Collection for the given Learning Package + key. + Get the Collection for the given Learning Package + collection code. """ return self.select_related('learning_package') \ - .get(learning_package_id=learning_package_id, key=key) + .get(learning_package_id=learning_package_id, collection_code=collection_code) class Collection(models.Model): @@ -105,10 +105,11 @@ class Collection(models.Model): learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) # Every collection is uniquely and permanently identified within its learning package - # by a 'key' that is set during creation. Both will appear in the + # by a 'code' that is set during creation. Both will appear in the # collection's opaque key: - # e.g. "lib-collection:lib:key" is the opaque key for a library collection. - key = key_field(db_column='_key') + # e.g. "lib-collection:{org_code}:{library_code}:{collection_code}" + # is the opaque key for a library collection. + collection_code = code_field() title = case_insensitive_char_field( null=False, @@ -170,14 +171,15 @@ class Collection(models.Model): class Meta: verbose_name_plural = "Collections" constraints = [ - # Keys are unique within a given LearningPackage. + # Collection codes are unique within a given LearningPackage. models.UniqueConstraint( fields=[ "learning_package", - "key", + "collection_code", ], name="oel_coll_uniq_lp_key", ), + code_field_check("collection_code", name="oel_coll_collection_code_regex"), ] indexes = [ models.Index( @@ -196,7 +198,7 @@ def __str__(self) -> str: """ User-facing string representation of a Collection. """ - return f"<{self.__class__.__name__}> (lp:{self.learning_package_id} {self.key}:{self.title})" + return f"<{self.__class__.__name__}> (lp:{self.learning_package_id} {self.collection_code}:{self.title})" class CollectionPublishableEntity(models.Model): diff --git a/src/openedx_content/applets/components/admin.py b/src/openedx_content/applets/components/admin.py index 3899fa050..80ea61bb6 100644 --- a/src/openedx_content/applets/components/admin.py +++ b/src/openedx_content/applets/components/admin.py @@ -37,16 +37,16 @@ class ComponentAdmin(ReadOnlyModelAdmin): """ Django admin configuration for Component """ - list_display = ("key", "uuid", "component_type", "created") + list_display = ("entity_ref", "uuid", "component_type", "created") readonly_fields = [ "learning_package", "uuid", "component_type", - "key", + "entity_ref", "created", ] list_filter = ("component_type", "learning_package") - search_fields = ["publishable_entity__uuid", "publishable_entity__key"] + search_fields = ["publishable_entity__uuid", "publishable_entity__entity_ref"] inlines = [ComponentVersionInline] @@ -69,13 +69,13 @@ def get_queryset(self, request): ) fields = [ - "key", + "path", "format_size", "rendered_data", ] readonly_fields = [ "media", - "key", + "path", "format_size", "rendered_data", ] diff --git a/src/openedx_content/applets/components/api.py b/src/openedx_content/applets/components/api.py index 6127cd3ec..69e4484f9 100644 --- a/src/openedx_content/applets/components/api.py +++ b/src/openedx_content/applets/components/api.py @@ -34,16 +34,15 @@ # to be callable only by other apps in the authoring package. __all__ = [ "get_or_create_component_type", - "get_or_create_component_type_by_entity_key", "create_component", "create_component_version", "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", @@ -74,45 +73,27 @@ def get_or_create_component_type(namespace: str, name: str) -> ComponentType: return component_type -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}:{local_key}"``. 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) - except ValueError as exc: - raise ValueError( - f"Invalid entity_key format: {entity_key!r}. " - "Expected format: '{namespace}:{type_name}:{local_key}'" - ) from exc - return get_or_create_component_type(namespace, type_name), local_key - - def create_component( learning_package_id: LearningPackage.ID, /, component_type: ComponentType, - local_key: str, + component_code: str, created: datetime, created_by: int | None, *, can_stand_alone: bool = True, ) -> Component: """ - Create a new Component (an entity like a Problem or Video) + Create a new Component (an entity like a Problem or Video). + + The ``entity_ref`` is always derived as + ``"{namespace}:{type_name}:{component_code}"``. """ - key = f"{component_type.namespace}:{component_type.name}:{local_key}" + entity_ref = f"{component_type.namespace}:{component_type.name}:{component_code}" with atomic(): publishable_entity = publishing_api.create_publishable_entity( learning_package_id, - key, + entity_ref, created, created_by, can_stand_alone=can_stand_alone @@ -121,7 +102,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 @@ -268,7 +249,7 @@ def create_next_component_version( ComponentVersionMedia.objects.create( media_id=media_pk, component_version=component_version, - key=key, + path=key, ) if ignore_previous_media: @@ -279,11 +260,11 @@ def create_next_component_version( last_version_media_mapping = ComponentVersionMedia.objects \ .filter(component_version=last_version) for cvrc in last_version_media_mapping: - if cvrc.key not in media_to_replace: + if cvrc.path not in media_to_replace: ComponentVersionMedia.objects.create( media_id=cvrc.media_id, component_version=component_version, - key=cvrc.key, + path=cvrc.path, ) return component_version @@ -293,7 +274,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, @@ -301,13 +282,13 @@ def create_component_and_version( # pylint: disable=too-many-positional-argumen can_stand_alone: bool = True, ) -> tuple[Component, ComponentVersion]: """ - Create a Component and associated ComponentVersion atomically + Create a Component and associated ComponentVersion atomically. """ with atomic(): component = create_component( learning_package_id, component_type, - local_key, + component_code, created, created_by, can_stand_alone=can_stand_alone, @@ -331,22 +312,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, ) @@ -366,12 +347,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. @@ -384,7 +365,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: @@ -423,12 +404,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 @@ -436,7 +417,7 @@ def get_components( # pylint: disable=too-many-positional-arguments def get_collection_components( learning_package_id: LearningPackage.ID, - collection_key: str, + collection_code: str, ) -> QuerySet[Component]: """ Returns a QuerySet of Components relating to the PublishableEntities in a Collection. @@ -445,18 +426,18 @@ def get_collection_components( """ return Component.objects.filter( learning_package_id=learning_package_id, - publishable_entity__collections__key=collection_key, + publishable_entity__collections__collection_code=collection_code, ).order_by('pk') def look_up_component_version_media( - learning_package_key: str, - component_key: str, + learning_package_ref: str, + entity_ref: str, version_num: int, - key: Path, + path: Path, ) -> ComponentVersionMedia: """ - Look up ComponentVersionMedia by human readable keys. + Look up ComponentVersionMedia by human readable identifiers. Can raise a django.core.exceptions.ObjectDoesNotExist error if there is no matching ComponentVersionMedia. @@ -465,10 +446,10 @@ def look_up_component_version_media( I don't know if we wantto make it a part of the public interface. """ queries = ( - Q(component_version__component__learning_package__key=learning_package_key) - & Q(component_version__component__publishable_entity__key=component_key) + Q(component_version__component__learning_package__package_ref=learning_package_ref) + & Q(component_version__component__publishable_entity__entity_ref=entity_ref) & Q(component_version__publishable_entity_version__version_num=version_num) - & Q(key=key) + & Q(path=path) ) return ComponentVersionMedia.objects \ .select_related( @@ -484,29 +465,29 @@ def create_component_version_media( component_version_id: int, media_id: int, /, - key: str, + path: str, ) -> ComponentVersionMedia: """ Add a Media to the given ComponentVersion - We don't allow keys that would be absolute paths, e.g. ones that start with + We don't allow paths that would be absolute, e.g. ones that start with '/'. Storing these causes headaches with building relative paths and because of mismatches with things that expect a leading slash and those that don't. So for safety and consistency, we strip off leading slashes and emit a warning when we do. """ - if key.startswith('/'): + if path.startswith('/'): logger.warning( "Absolute paths are not supported: " f"removed leading '/' from ComponentVersion {component_version_id} " - f"media key: {repr(key)} (media_id: {media_id})" + f"media path: {repr(path)} (media_id: {media_id})" ) - key = key.lstrip('/') + path = path.lstrip('/') cvrc, _created = ComponentVersionMedia.objects.get_or_create( component_version_id=component_version_id, media_id=media_id, - key=key, + path=path, ) return cvrc @@ -529,13 +510,13 @@ def _get_component_version_info_headers(component_version: ComponentVersion) -> learning_package = component.learning_package return { # Component - "X-Open-edX-Component-Key": component.publishable_entity.key, + "X-Open-edX-Component-Key": component.publishable_entity.entity_ref, "X-Open-edX-Component-Uuid": component.uuid, # Component Version "X-Open-edX-Component-Version-Uuid": component_version.uuid, "X-Open-edX-Component-Version-Num": str(component_version.version_num), # Learning Package - "X-Open-edX-Learning-Package-Key": learning_package.key, + "X-Open-edX-Learning-Package-Key": learning_package.package_ref, "X-Open-edX-Learning-Package-Uuid": learning_package.uuid, } @@ -621,7 +602,7 @@ def _error_header(error: AssetError) -> dict[str, str]: # Check: Does the ComponentVersion have the requested asset (Media)? try: - cv_media = component_version.componentversionmedia_set.get(key=asset_path) + cv_media = component_version.componentversionmedia_set.get(path=asset_path) except ComponentVersionMedia.DoesNotExist: logger.error(f"ComponentVersion {component_version_uuid} has no asset {asset_path}") info_headers.update( diff --git a/src/openedx_content/applets/components/models.py b/src/openedx_content/applets/components/models.py index 6fbab2ba3..1a43aff97 100644 --- a/src/openedx_content/applets/components/models.py +++ b/src/openedx_content/applets/components/models.py @@ -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, ref_field from openedx_django_lib.managers import WithRelationsManager from ..media.models import Media @@ -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 ----------------------- @@ -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.entity_ref 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", ), @@ -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): @@ -256,10 +257,9 @@ class ComponentVersionMedia(models.Model): For instance, a Video ComponentVersion might be associated with multiple transcripts in different languages. - When Media is associated with an ComponentVersion, it has some local - key that is unique within the the context of that ComponentVersion. This - allows the ComponentVersion to do things like store an image file and - reference it by a "path" key. + When Media is associated with a ComponentVersion, it has a ``path`` + that is unique within the context of that ComponentVersion. This is + used as a local file-path-like identifier, e.g. "static/image.png". Media is immutable and sharable across multiple ComponentVersions. """ @@ -267,20 +267,15 @@ class ComponentVersionMedia(models.Model): component_version = models.ForeignKey(ComponentVersion, on_delete=models.CASCADE) media = models.ForeignKey(Media, on_delete=models.RESTRICT) - # "key" is a reserved word for MySQL, so we're temporarily using the column - # name of "_key" to avoid breaking downstream tooling. A possible - # alternative name for this would be "path", since it's most often used as - # an internal file path. However, we might also want to put special - # identifiers that don't map as cleanly to file paths at some point. - key = key_field(db_column="_key") + # path is a local file-path-like identifier for the media within a + # ComponentVersion. + path = ref_field() class Meta: constraints = [ - # Uniqueness is only by ComponentVersion and key. If for some reason - # a ComponentVersion wants to associate the same piece of Media - # with two different identifiers, that is permitted. + # Uniqueness is only by ComponentVersion and path. models.UniqueConstraint( - fields=["component_version", "key"], + fields=["component_version", "path"], name="oel_cvcontent_uniq_cv_key", ), ] diff --git a/src/openedx_content/applets/containers/admin.py b/src/openedx_content/applets/containers/admin.py index df2a60ecd..ae86e83c7 100644 --- a/src/openedx_content/applets/containers/admin.py +++ b/src/openedx_content/applets/containers/admin.py @@ -89,11 +89,13 @@ class ContainerAdmin(ReadOnlyModelAdmin): Django admin configuration for Container """ - list_display = ("key", "container_type_display", "published", "draft", "created") + list_display = ("container_code", "container_type_display", "published", "draft", "created") fields = [ "pk", "publishable_entity", "learning_package", + "container_code", + "container_type_display", "published", "draft", "created", @@ -101,14 +103,15 @@ class ContainerAdmin(ReadOnlyModelAdmin): "see_also", "most_recent_parent_entity_list", ] + # container_code is a model field; container_type_display is a method readonly_fields = fields # type: ignore[assignment] - search_fields = ["publishable_entity__uuid", "publishable_entity__key"] + search_fields = ["publishable_entity__uuid", "publishable_entity__entity_ref", "container_code"] inlines = [ContainerVersionInlineForContainer] def learning_package(self, obj: Container) -> SafeText: return model_detail_link( obj.publishable_entity.learning_package, - obj.publishable_entity.learning_package.key, + obj.publishable_entity.learning_package.package_ref, ) def get_queryset(self, request): @@ -184,7 +187,7 @@ class ContainerVersionInlineForEntityList(admin.TabularInline): fields = [ "pk", "version_num", - "container_key", + "container_code", "title", "created", "created_by", @@ -203,8 +206,8 @@ def get_queryset(self, request): ) ) - def container_key(self, obj: ContainerVersion) -> SafeText: - return model_detail_link(obj.container, obj.container.key) + def container_code(self, obj: ContainerVersion) -> SafeText: + return model_detail_link(obj.container, obj.container.container_code) class EntityListRowInline(admin.TabularInline): @@ -238,7 +241,7 @@ def pinned_version_num(self, obj: EntityListRow): def entity_models(self, obj: EntityListRow): return format_html( "{}", - model_detail_link(obj.entity, obj.entity.key), + model_detail_link(obj.entity, obj.entity.entity_ref), one_to_one_related_model_html(obj.entity), ) @@ -301,7 +304,7 @@ def recent_container(self, obj: EntityList) -> SafeText | None: Link to the Container of the newest ContainerVersion that references this EntityList """ if latest := _latest_container_version(obj): - return format_html("of: {}", model_detail_link(latest.container, latest.container.key)) + return format_html("of: {}", model_detail_link(latest.container, latest.container.entity_ref)) else: return None @@ -314,7 +317,7 @@ def recent_container_package(self, obj: EntityList) -> SafeText | None: "in: {}", model_detail_link( latest.container.publishable_entity.learning_package, - latest.container.publishable_entity.learning_package.key, + latest.container.publishable_entity.learning_package.package_ref, ), ) else: diff --git a/src/openedx_content/applets/containers/api.py b/src/openedx_content/applets/containers/api.py index 9fe192ea8..ea7079c68 100644 --- a/src/openedx_content/applets/containers/api.py +++ b/src/openedx_content/applets/containers/api.py @@ -55,7 +55,7 @@ "create_next_container_version", "get_container", "get_container_version", - "get_container_by_key", + "get_container_by_ref", "get_all_container_subclasses", "get_container_subclass", "get_container_type_code_of", @@ -68,7 +68,7 @@ "contains_unpublished_changes", "get_containers_with_entity", "get_container_children_count", - "get_container_children_entities_keys", + "get_container_children_entity_refs", ] @@ -136,10 +136,10 @@ def parse(entities: EntityListInput) -> list[ParsedEntityReference]: def create_container( learning_package_id: LearningPackage.ID, - key: str, created: datetime, created_by: int | None, *, + container_code: str, container_cls: type[ContainerModel], can_stand_alone: bool = True, ) -> ContainerModel: @@ -149,9 +149,10 @@ def create_container( Args: learning_package_id: The ID of the learning package that contains the container. - key: The key of the container. created: The date and time the container was created. created_by: The ID of the user who created the container + container_code: A local slug identifier for the container, unique within + the learning package (regardless of container type). container_cls: The subclass of container to create (e.g. `Unit`) can_stand_alone: Set to False when created as part of containers @@ -160,17 +161,20 @@ def create_container( """ assert issubclass(container_cls, Container) assert container_cls is not Container, "Creating plain containers is not allowed; use a subclass of Container" + entity_ref = container_code with atomic(): publishable_entity = publishing_api.create_publishable_entity( learning_package_id, - key, + entity_ref, created, created_by, can_stand_alone=can_stand_alone, ) container = container_cls.objects.create( publishable_entity=publishable_entity, + learning_package_id=learning_package_id, container_type=container_cls.get_container_type(), + container_code=container_code, ) return container @@ -339,8 +343,8 @@ def create_container_version( def create_container_and_version( learning_package_id: LearningPackage.ID, - key: str, *, + container_code: str, title: str, container_cls: type[ContainerModel], entities: EntityListInput | None = None, @@ -353,7 +357,8 @@ def create_container_and_version( Args: learning_package_id: The learning package ID. - key: The key. + container_code: A local slug identifier for the container, unique within + the learning package (regardless of container type). title: The title of the new container. container_cls: The subclass of container to create (e.g. Unit) entities: List of the entities that will comprise the entity list, in @@ -368,9 +373,9 @@ def create_container_and_version( with atomic(savepoint=False): container = create_container( learning_package_id, - key, created, created_by, + container_code=container_code, can_stand_alone=can_stand_alone, container_cls=container_cls, ) @@ -564,22 +569,22 @@ def get_container_version(container_version_pk: int) -> ContainerVersion: return ContainerVersion.objects.get(pk=container_version_pk) -def get_container_by_key(learning_package_id: LearningPackage.ID, /, key: str) -> Container: +def get_container_by_ref(learning_package_id: LearningPackage.ID, /, entity_ref: str) -> Container: """ [ 🛑 UNSTABLE ] - Get a container by its learning package and primary key. + Get a container by its learning package and entity ref. Args: learning_package_id: The ID of the learning package that contains the container. - key: The primary key of the container. + entity_ref: The entity ref of the container. Returns: - The container with the given primary key (as `Container`, not as its typed subclass). + The container with the given entity ref (as `Container`, not as its typed subclass). """ try: return Container.objects.select_related("container_type").get( publishable_entity__learning_package_id=learning_package_id, - publishable_entity__key=key, + publishable_entity__entity_ref=entity_ref, ) except Container.DoesNotExist: # Check if it's the container or the learning package that does not exist: @@ -867,15 +872,17 @@ def get_container_children_count( return container_version.entity_list.entitylistrow_set.filter(**filter_deleted).count() -def get_container_children_entities_keys(container_version: ContainerVersion) -> list[str]: +def get_container_children_entity_refs(container_version: ContainerVersion) -> list[str]: """ - Fetch the list of entity keys for all entities in the given container version. + Fetch the list of entity refs for all entities in the given container version. Args: - container_version: The ContainerVersion to fetch the entity keys for. + container_version: The ContainerVersion to fetch the entity refs for. Returns: - A list of entity keys for all entities in the container version, ordered by entity key. + A list of entity refs for all entities in the container version, ordered by position. """ return list( - container_version.entity_list.entitylistrow_set.values_list("entity__key", flat=True).order_by("order_num") + container_version.entity_list.entitylistrow_set + .values_list("entity__entity_ref", flat=True) + .order_by("order_num") ) diff --git a/src/openedx_content/applets/containers/models.py b/src/openedx_content/applets/containers/models.py index ece19c799..dfe28c46f 100644 --- a/src/openedx_content/applets/containers/models.py +++ b/src/openedx_content/applets/containers/models.py @@ -11,8 +11,9 @@ from django.db import models from typing_extensions import deprecated -from openedx_django_lib.fields import case_sensitive_char_field +from openedx_django_lib.fields import case_sensitive_char_field, code_field +from ..publishing.models.learning_package import LearningPackage from ..publishing.models.publishable_entity import ( PublishableEntity, PublishableEntityMixin, @@ -171,6 +172,12 @@ class Container(PublishableEntityMixin): olx_tag_name: str = "" _type_instance: ContainerType # Cache used by get_container_type() + # This foreign key is technically redundant because we're already locked to + # a single LearningPackage through our publishable_entity relation. However, + # having this foreign key directly allows us to make indexes that efficiently + # query by other Container fields within a given LearningPackage. + learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) + # The type of the container. Cannot be changed once the container is created. container_type = models.ForeignKey( ContainerType, @@ -179,6 +186,11 @@ class Container(PublishableEntityMixin): editable=False, ) + # container_code is an identifier that is local to the learning_package. + # Unlike component_code, it is unique across all container types within + # the same LearningPackage. + container_code = code_field() + @property def id(self) -> ID: return cast(Container.ID, self.publishable_entity_id) @@ -194,6 +206,14 @@ def pk(self): # override this with a deprecated marker, so it shows a warning in developer's IDEs like VS Code. return self.id + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["learning_package", "container_code"], + name="oel_container_uniq_lp_cc", + ), + ] + @classmethod def validate_entity(cls, entity: PublishableEntity) -> None: """ diff --git a/src/openedx_content/applets/publishing/admin.py b/src/openedx_content/applets/publishing/admin.py index 85adcc5d9..7488937bf 100644 --- a/src/openedx_content/applets/publishing/admin.py +++ b/src/openedx_content/applets/publishing/admin.py @@ -26,10 +26,10 @@ class LearningPackageAdmin(ReadOnlyModelAdmin): """ Read-only admin for LearningPackage model """ - fields = ["key", "title", "uuid", "created", "updated"] - readonly_fields = ["key", "title", "uuid", "created", "updated"] - list_display = ["key", "title", "uuid", "created", "updated"] - search_fields = ["key", "title", "uuid"] + fields = ["package_ref", "title", "uuid", "created", "updated"] + readonly_fields = ["package_ref", "title", "uuid", "created", "updated"] + list_display = ["package_ref", "title", "uuid", "created", "updated"] + search_fields = ["package_ref", "title", "uuid"] class PublishLogRecordTabularInline(admin.TabularInline): @@ -102,7 +102,7 @@ class PublishableEntityVersionTabularInline(admin.TabularInline): def dependencies_list(self, version: PublishableEntityVersion): identifiers = sorted( - [str(dep.key) for dep in version.dependencies.all()] + [str(dep.entity_ref) for dep in version.dependencies.all()] ) return "\n".join(identifiers) @@ -152,7 +152,7 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin): inlines = [PublishableEntityVersionTabularInline] list_display = [ - "key", + "entity_ref", "published_version", "draft_version", "uuid", @@ -162,10 +162,10 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin): "can_stand_alone", ] list_filter = ["learning_package", PublishStatusFilter] - search_fields = ["key", "uuid"] + search_fields = ["entity_ref", "uuid"] fields = [ - "key", + "entity_ref", "published_version", "draft_version", "uuid", @@ -176,7 +176,7 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin): "can_stand_alone", ] readonly_fields = [ - "key", + "entity_ref", "published_version", "draft_version", "uuid", @@ -295,7 +295,7 @@ class DraftChangeLogRecordTabularInline(admin.TabularInline): def get_queryset(self, request): queryset = super().get_queryset(request) return queryset.select_related("entity", "old_version", "new_version") \ - .order_by("entity__key") + .order_by("entity__entity_ref") def old_version_num(self, draft_change: DraftChangeLogRecord): if draft_change.old_version is None: diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 2fe45a7c4..2a6bf04fe 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -43,14 +43,14 @@ # to be callable only by other apps in the authoring package. __all__ = [ "get_learning_package", - "get_learning_package_by_key", + "get_learning_package_by_ref", "create_learning_package", "update_learning_package", "learning_package_exists", "create_publishable_entity", "create_publishable_entity_version", "get_publishable_entity", - "get_publishable_entity_by_key", + "get_publishable_entity_by_ref", "get_publishable_entities", "get_last_publish", "get_all_drafts", @@ -76,22 +76,22 @@ def get_learning_package(learning_package_id: LearningPackage.ID, /) -> Learning return LearningPackage.objects.get(id=learning_package_id) -def get_learning_package_by_key(key: str) -> LearningPackage: +def get_learning_package_by_ref(package_ref: str) -> LearningPackage: """ - Get LearningPackage by key. + Get LearningPackage by its package_ref. Can throw a NotFoundError """ - return LearningPackage.objects.get(key=key) + return LearningPackage.objects.get(package_ref=package_ref) def create_learning_package( - key: str, title: str, description: str = "", created: datetime | None = None + package_ref: str, title: str, description: str = "", created: datetime | None = None ) -> LearningPackage: """ Create a new LearningPackage. - The ``key`` must be unique. + The ``package_ref`` must be unique. Errors that can be raised: @@ -101,7 +101,7 @@ def create_learning_package( created = datetime.now(tz=timezone.utc) package = LearningPackage( - key=key, + package_ref=package_ref, title=title, description=description, created=created, @@ -116,7 +116,7 @@ def create_learning_package( def update_learning_package( learning_package_id: LearningPackage.ID, /, - key: str | None = None, + package_ref: str | None = None, title: str | None = None, description: str | None = None, updated: datetime | None = None, @@ -130,11 +130,11 @@ def update_learning_package( # If no changes were requested, there's nothing to update, so just return # the LearningPackage as-is. - if all(field is None for field in [key, title, description, updated]): + if all(field is None for field in [package_ref, title, description, updated]): return lp - if key is not None: - lp.key = key + if package_ref is not None: + lp.package_ref = package_ref if title is not None: lp.title = title if description is not None: @@ -150,17 +150,17 @@ def update_learning_package( return lp -def learning_package_exists(key: str) -> bool: +def learning_package_exists(package_ref: str) -> bool: """ - Check whether a LearningPackage with a particular key exists. + Check whether a LearningPackage with a particular package_ref exists. """ - return LearningPackage.objects.filter(key=key).exists() + return LearningPackage.objects.filter(package_ref=package_ref).exists() def create_publishable_entity( learning_package_id: LearningPackage.ID, /, - key: str, + entity_ref: str, created: datetime, # User ID who created this created_by: int | None, @@ -175,7 +175,7 @@ def create_publishable_entity( """ return PublishableEntity.objects.create( learning_package_id=learning_package_id, - key=key, + entity_ref=entity_ref, created=created, created_by_id=created_by, can_stand_alone=can_stand_alone, @@ -286,10 +286,10 @@ def get_publishable_entity(publishable_entity_id: PublishableEntity.ID, /) -> Pu return PublishableEntity.objects.get(pk=publishable_entity_id) -def get_publishable_entity_by_key(learning_package_id: LearningPackage.ID, /, key: str) -> PublishableEntity: +def get_publishable_entity_by_ref(learning_package_id: LearningPackage.ID, /, entity_ref: str) -> PublishableEntity: return PublishableEntity.objects.get( learning_package_id=learning_package_id, - key=key, + entity_ref=entity_ref, ) diff --git a/src/openedx_content/applets/publishing/models/learning_package.py b/src/openedx_content/applets/publishing/models/learning_package.py index 9266b3a99..9baf9a5f2 100644 --- a/src/openedx_content/applets/publishing/models/learning_package.py +++ b/src/openedx_content/applets/publishing/models/learning_package.py @@ -12,8 +12,8 @@ TypedAutoField, case_insensitive_char_field, immutable_uuid_field, - key_field, manual_date_time_field, + ref_field, ) @@ -45,19 +45,15 @@ def pk(self) -> ID: uuid = immutable_uuid_field() - # "key" is a reserved word for MySQL, so we're temporarily using the column - # name of "_key" to avoid breaking downstream tooling. There's an open - # question as to whether this field needs to exist at all, or whether the - # top level library key it's currently used for should be entirely in the - # LibraryContent model. - key = key_field(db_column="_key") + # package_ref is an opaque reference string for the LearningPackage. + package_ref = ref_field() title = case_insensitive_char_field(max_length=500, blank=False) # TODO: We should probably defer this field, since many things pull back # LearningPackage as select_related. Usually those relations only care about - # the UUID and key, so maybe it makes sense to separate the model at some - # point. + # the UUID and package_ref, so maybe it makes sense to separate the model at + # some point. description = MultiCollationTextField( blank=True, null=False, @@ -76,16 +72,13 @@ def pk(self) -> ID: updated = manual_date_time_field() def __str__(self): - return f"{self.key}" + return f"{self.package_ref}" class Meta: constraints = [ - # LearningPackage keys must be globally unique. This is something - # that might be relaxed in the future if this system were to be - # extensible to something like multi-tenancy, in which case we'd tie - # it to something like a Site or Org. + # package_refs must be globally unique. models.UniqueConstraint( - fields=["key"], + fields=["package_ref"], name="oel_publishing_lp_uniq_key", ) ] diff --git a/src/openedx_content/applets/publishing/models/publishable_entity.py b/src/openedx_content/applets/publishing/models/publishable_entity.py index 907b50248..2e363f350 100644 --- a/src/openedx_content/applets/publishing/models/publishable_entity.py +++ b/src/openedx_content/applets/publishing/models/publishable_entity.py @@ -19,8 +19,8 @@ TypedBigAutoField, case_insensitive_char_field, immutable_uuid_field, - key_field, manual_date_time_field, + ref_field, ) from openedx_django_lib.managers import WithRelationsManager @@ -85,7 +85,7 @@ class PublishableEntity(models.Model): * Published things need to have the right identifiers so they can be used throughout the system, and the UUID is serving the role of ISBN in physical book publishing. - * We want to be able to enforce the idea that "key" is locally unique across + * We want to be able to enforce the idea that "entity_ref" is locally unique across all PublishableEntities within a given LearningPackage. Component and Unit can't do that without a shared model. @@ -128,10 +128,11 @@ class IDField(TypedBigAutoField[ID]): # Boilerplate for fully-typed ID field. related_name="publishable_entities", ) - # "key" is a reserved word for MySQL, so we're temporarily using the column - # name of "_key" to avoid breaking downstream tooling. Consider renaming - # this later. - key = key_field(db_column="_key") + # entity_ref is an opaque reference string assigned by the creator of this + # entity (e.g. derived from component_type + component_code for Components). + # Consumers must treat it as an atomic string — do not parse or reconstruct + # it. + entity_ref = ref_field() created = manual_date_time_field() created_by = models.ForeignKey( @@ -152,21 +153,19 @@ def pk(self) -> ID: class Meta: constraints = [ - # Keys are unique within a given LearningPackage. + # entity_refs are unique within a given LearningPackage. models.UniqueConstraint( fields=[ "learning_package", - "key", + "entity_ref", ], name="oel_pub_ent_uniq_lp_key", ) ] indexes = [ - # Global Key Index: - # * Search by key across all PublishableEntities on the site. This - # would be a support-oriented tool from Django Admin. + # Global entity_ref index for support-oriented admin searches. models.Index( - fields=["key"], + fields=["entity_ref"], name="oel_pub_ent_idx_key", ), # LearningPackage (reverse) Created Index: @@ -183,7 +182,7 @@ class Meta: verbose_name_plural = "Publishable Entities" def __str__(self): - return f"{self.key}" + return f"{self.entity_ref}" class PublishableEntityVersion(models.Model): @@ -250,7 +249,7 @@ class PublishableEntityVersion(models.Model): ) def __str__(self): - return f"{self.entity.key} @ v{self.version_num} - {self.title}" + return f"{self.entity.entity_ref} @ v{self.version_num} - {self.title}" class Meta: constraints = [ @@ -367,8 +366,8 @@ def can_stand_alone(self) -> bool: return self.publishable_entity.can_stand_alone @property - def key(self) -> str: - return self.publishable_entity.key + def entity_ref(self) -> str: + return self.publishable_entity.entity_ref @property def created(self) -> datetime: @@ -415,7 +414,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, diff --git a/src/openedx_content/applets/sections/api.py b/src/openedx_content/applets/sections/api.py index 0aee1c7bd..9f011c792 100644 --- a/src/openedx_content/applets/sections/api.py +++ b/src/openedx_content/applets/sections/api.py @@ -31,8 +31,8 @@ def get_section(section_id: Section.ID, /): def create_section_and_version( learning_package_id: LearningPackage.ID, - key: str, *, + container_code: str, title: str, subsections: Iterable[Subsection | SubsectionVersion] | None = None, created: datetime, @@ -48,7 +48,7 @@ def create_section_and_version( """ section, sv = containers_api.create_container_and_version( learning_package_id, - key=key, + container_code=container_code, title=title, entities=subsections, created=created, diff --git a/src/openedx_content/applets/subsections/api.py b/src/openedx_content/applets/subsections/api.py index 5d2a6cdb0..934caa058 100644 --- a/src/openedx_content/applets/subsections/api.py +++ b/src/openedx_content/applets/subsections/api.py @@ -31,8 +31,8 @@ def get_subsection(subsection_id: Subsection.ID, /): def create_subsection_and_version( learning_package_id: LearningPackage.ID, - key: str, *, + container_code: str, title: str, units: Iterable[Unit | UnitVersion] | None = None, created: datetime, @@ -48,7 +48,7 @@ def create_subsection_and_version( """ subsection, sv = containers_api.create_container_and_version( learning_package_id, - key=key, + container_code=container_code, title=title, entities=units, created=created, diff --git a/src/openedx_content/applets/units/api.py b/src/openedx_content/applets/units/api.py index cde86bf0b..37697da0f 100644 --- a/src/openedx_content/applets/units/api.py +++ b/src/openedx_content/applets/units/api.py @@ -31,8 +31,8 @@ def get_unit(unit_id: Unit.ID, /): def create_unit_and_version( learning_package_id: LearningPackage.ID, - key: str, *, + container_code: str, title: str, components: Iterable[Component | ComponentVersion] | None = None, created: datetime, @@ -48,7 +48,7 @@ def create_unit_and_version( """ unit, uv = containers_api.create_container_and_version( learning_package_id, - key=key, + container_code=container_code, title=title, entities=components, created=created, diff --git a/src/openedx_content/applets/units/models.py b/src/openedx_content/applets/units/models.py index 913ea4890..109fb38b9 100644 --- a/src/openedx_content/applets/units/models.py +++ b/src/openedx_content/applets/units/models.py @@ -1,6 +1,7 @@ """ Models that implement units """ +from __future__ import annotations from typing import NewType, cast, override diff --git a/src/openedx_content/management/commands/add_assets_to_component.py b/src/openedx_content/management/commands/add_assets_to_component.py index fc207e20a..21fc2cbe0 100644 --- a/src/openedx_content/management/commands/add_assets_to_component.py +++ b/src/openedx_content/management/commands/add_assets_to_component.py @@ -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_ref class Command(BaseCommand): @@ -26,14 +26,14 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( - "learning_package_key", + "learning_package_ref", type=str, - help="LearningPackage.key value for where the Component is located." + help="LearningPackage.package_ref value for where the Component is located." ) parser.add_argument( - "component_key", + "entity_ref", type=str, - help="Component.key that you want to add assets to." + help="Entity ref (e.g. 'xblock.v1:problem:my_component') of the Component to add assets to." ) parser.add_argument( "file_mappings", @@ -53,34 +53,34 @@ def handle(self, *args, **options): """ Add files to a Component as ComponentVersion -> Content associations. """ - learning_package_key = options["learning_package_key"] - component_key = options["component_key"] + learning_package_ref = options["learning_package_ref"] + entity_ref = options["entity_ref"] file_mappings = options["file_mappings"] - learning_package = get_learning_package_by_key(learning_package_key) + learning_package = get_learning_package_by_ref(learning_package_ref) # 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 = entity_ref.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, ) self.stdout.write( f"Created v{next_version.version_num} of " - f"{next_version.component.key} ({next_version.uuid}):" + f"{next_version.component.entity_ref} ({next_version.uuid}):" ) for cvm in next_version.componentversionmedia_set.all(): - self.stdout.write(f"- {cvm.key} ({cvm.uuid})") + self.stdout.write(f"- {cvm.path} ({cvm.uuid})") diff --git a/src/openedx_content/management/commands/lp_dump.py b/src/openedx_content/management/commands/lp_dump.py index c23038b2d..af2c3dea7 100644 --- a/src/openedx_content/management/commands/lp_dump.py +++ b/src/openedx_content/management/commands/lp_dump.py @@ -24,7 +24,7 @@ class Command(BaseCommand): help = 'Export a learning package to a zip file.' def add_arguments(self, parser): - parser.add_argument('lp_key', type=str, help='The key of the LearningPackage to dump') + parser.add_argument('package_ref', type=str, help='The package_ref of the LearningPackage to dump') parser.add_argument('file_name', type=str, help='The name of the output zip file') parser.add_argument( '--username', @@ -40,7 +40,7 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): - lp_key = options['lp_key'] + package_ref = options['package_ref'] file_name = options['file_name'] username = options['username'] origin_server = options['origin_server'] @@ -52,18 +52,18 @@ def handle(self, *args, **options): if username: user = User.objects.get(username=username) start_time = time.time() - create_zip_file(lp_key, file_name, user=user, origin_server=origin_server) + create_zip_file(package_ref, file_name, user=user, origin_server=origin_server) elapsed = time.time() - start_time - message = f'{lp_key} written to {file_name} (create_zip_file: {elapsed:.2f} seconds)' + message = f'{package_ref} written to {file_name} (create_zip_file: {elapsed:.2f} seconds)' self.stdout.write(self.style.SUCCESS(message)) except LearningPackage.DoesNotExist as exc: - message = f"Learning package with key {lp_key} not found" + message = f"Learning package {package_ref!r} not found" raise CommandError(message) from exc except Exception as e: - message = f"Failed to export learning package '{lp_key}': {e}" + message = f"Failed to export learning package {package_ref!r}: {e}" logger.exception( - "Failed to create zip file %s (learning‑package key %s)", + "Failed to create zip file %s (package_ref %s)", file_name, - lp_key, + package_ref, ) raise CommandError(message) from e diff --git a/src/openedx_content/migrations/0008_rename_collection_key_to_collection_code.py b/src/openedx_content/migrations/0008_rename_collection_key_to_collection_code.py new file mode 100644 index 000000000..9bdab4915 --- /dev/null +++ b/src/openedx_content/migrations/0008_rename_collection_key_to_collection_code.py @@ -0,0 +1,73 @@ +""" +Rename Collection.key -> Collection.collection_code and change from key_field to code_field. +""" +import re + +import django.core.validators +import django.db.models.lookups +from django.conf import settings +from django.db import migrations, models + +import openedx_django_lib.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('openedx_content', '0007_publishlogrecord_direct'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # Drop old constraint (references the old field name). + migrations.RemoveConstraint( + model_name='collection', + name='oel_coll_uniq_lp_key', + ), + # Rename the column. + migrations.RenameField( + model_name='collection', + old_name='key', + new_name='collection_code', + ), + # Change from key_field (max_length=500, no validator) to code_field + # (max_length=255, with regex validator). + migrations.AlterField( + model_name='collection', + name='collection_code', + field=openedx_django_lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, + max_length=255, + validators=[ + django.core.validators.RegexValidator( + re.compile('^[a-zA-Z0-9_.-]+\\Z'), + 'Enter a valid "code name" consisting of letters, numbers, underscores, hyphens, or periods.', + 'invalid', + ), + ], + ), + ), + # Re-add uniqueness constraint with the new field name. + migrations.AddConstraint( + model_name='collection', + constraint=models.UniqueConstraint( + fields=('learning_package', 'collection_code'), + name='oel_coll_uniq_lp_key', + ), + ), + # DB-level regex check constraint. + migrations.AddConstraint( + model_name='collection', + constraint=models.CheckConstraint( + condition=django.db.models.lookups.Regex( + models.F('collection_code'), + '^[a-zA-Z0-9_.-]+\\Z', + ), + name='oel_coll_collection_code_regex', + violation_error_message=( + 'Enter a valid "code name" consisting of letters, numbers,' + ' underscores, hyphens, or periods.' + ), + ), + ), + ] diff --git a/src/openedx_content/migrations/0009_rename_component_local_key_to_component_code.py b/src/openedx_content/migrations/0009_rename_component_local_key_to_component_code.py new file mode 100644 index 000000000..e5819a9d5 --- /dev/null +++ b/src/openedx_content/migrations/0009_rename_component_local_key_to_component_code.py @@ -0,0 +1,66 @@ +""" +Rename Component.local_key -> Component.component_code and change from key_field to code_field. +""" +import re + +import django.core.validators +from django.db import migrations, models + +import openedx_django_lib.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('openedx_content', '0008_rename_collection_key_to_collection_code'), + ] + + operations = [ + # Drop old constraint and index (reference the old field name). + migrations.RemoveConstraint( + model_name='component', + name='oel_component_uniq_lc_ct_lk', + ), + migrations.RemoveIndex( + model_name='component', + name='oel_component_idx_ct_lk', + ), + # Rename the column. + migrations.RenameField( + model_name='component', + old_name='local_key', + new_name='component_code', + ), + # Change from key_field (max_length=500, no validator) to code_field + # (max_length=255, with regex validator). + migrations.AlterField( + model_name='component', + name='component_code', + field=openedx_django_lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, + max_length=255, + validators=[ + django.core.validators.RegexValidator( + re.compile('^[a-zA-Z0-9_.-]+\\Z'), + 'Enter a valid "code name" consisting of letters, numbers, underscores, hyphens, or periods.', + 'invalid', + ), + ], + ), + ), + # Re-add constraint and index with the new field name. + migrations.AddConstraint( + model_name='component', + constraint=models.UniqueConstraint( + fields=('learning_package', 'component_type', 'component_code'), + name='oel_component_uniq_lc_ct_lk', + ), + ), + migrations.AddIndex( + model_name='component', + index=models.Index( + fields=['component_type', 'component_code'], + name='oel_component_idx_ct_lk', + ), + ), + ] diff --git a/src/openedx_content/migrations/0010_add_container_code.py b/src/openedx_content/migrations/0010_add_container_code.py new file mode 100644 index 000000000..acb42ddde --- /dev/null +++ b/src/openedx_content/migrations/0010_add_container_code.py @@ -0,0 +1,88 @@ +import re + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + +import openedx_django_lib.fields + + +def backfill_container_code(apps, schema_editor): + """ + Backfill container_code and learning_package from publishable_entity. + + For existing containers, container_code is set to the entity key (the + only identifier available at this point). Future containers will have + container_code set by the caller. + """ + Container = apps.get_model("openedx_content", "Container") + for container in Container.objects.select_related("publishable_entity__learning_package").all(): + container.learning_package = container.publishable_entity.learning_package + container.container_code = container.publishable_entity.key + container.save(update_fields=["learning_package", "container_code"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("openedx_content", "0009_rename_component_local_key_to_component_code"), + ] + + operations = [ + # 1. Add learning_package FK (nullable initially for backfill) + migrations.AddField( + model_name="container", + name="learning_package", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="openedx_content.learningpackage", + ), + ), + # 2. Add container_code (nullable initially for backfill) + migrations.AddField( + model_name="container", + name="container_code", + field=openedx_django_lib.fields.MultiCollationCharField( + db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"}, + max_length=255, + null=True, + ), + ), + # 3. Backfill both fields from publishable_entity + migrations.RunPython(backfill_container_code, migrations.RunPython.noop), + # 4. Make both fields non-nullable and add regex validation to container_code + migrations.AlterField( + model_name="container", + name="learning_package", + field=models.ForeignKey( + null=False, + on_delete=django.db.models.deletion.CASCADE, + to="openedx_content.learningpackage", + ), + ), + migrations.AlterField( + model_name="container", + name="container_code", + field=openedx_django_lib.fields.MultiCollationCharField( + db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"}, + max_length=255, + validators=[ + django.core.validators.RegexValidator( + re.compile(r"^[a-zA-Z0-9_.-]+\Z"), + "Enter a valid \"code name\" consisting of letters, numbers, " + "underscores, hyphens, or periods.", + "invalid", + ), + ], + ), + ), + # 5. Add uniqueness constraint + migrations.AddConstraint( + model_name="container", + constraint=models.UniqueConstraint( + fields=["learning_package", "container_code"], + name="oel_container_uniq_lp_cc", + ), + ), + ] diff --git a/src/openedx_content/migrations/0011_rename_entity_key_and_package_key_to_refs.py b/src/openedx_content/migrations/0011_rename_entity_key_and_package_key_to_refs.py new file mode 100644 index 000000000..90db00a16 --- /dev/null +++ b/src/openedx_content/migrations/0011_rename_entity_key_and_package_key_to_refs.py @@ -0,0 +1,84 @@ +""" +Rename PublishableEntity.key -> entity_ref and LearningPackage.key -> package_ref. + +Both fields previously had db_column='_key'; the AlterField steps drop that +override, which causes Django's schema editor to rename the DB column too. +""" +from django.db import migrations, models + +import openedx_django_lib.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('openedx_content', '0010_add_container_code'), + ] + + operations = [ + # ---- PublishableEntity.key -> entity_ref ---- + migrations.RemoveConstraint( + model_name='publishableentity', + name='oel_pub_ent_uniq_lp_key', + ), + migrations.RemoveIndex( + model_name='publishableentity', + name='oel_pub_ent_idx_key', + ), + migrations.RenameField( + model_name='publishableentity', + old_name='key', + new_name='entity_ref', + ), + # RenameField only changes the Django field name; the DB column is still + # '_key' (set via db_column). AlterField drops db_column, so Django sees + # old column='_key' vs new column='entity_ref' and renames it. + migrations.AlterField( + model_name='publishableentity', + name='entity_ref', + field=openedx_django_lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, + max_length=500, + ), + ), + migrations.AddConstraint( + model_name='publishableentity', + constraint=models.UniqueConstraint( + fields=['learning_package', 'entity_ref'], + name='oel_pub_ent_uniq_lp_key', + ), + ), + migrations.AddIndex( + model_name='publishableentity', + index=models.Index( + fields=['entity_ref'], + name='oel_pub_ent_idx_key', + ), + ), + + # ---- LearningPackage.key -> package_ref ---- + migrations.RemoveConstraint( + model_name='learningpackage', + name='oel_publishing_lp_uniq_key', + ), + migrations.RenameField( + model_name='learningpackage', + old_name='key', + new_name='package_ref', + ), + migrations.AlterField( + model_name='learningpackage', + name='package_ref', + field=openedx_django_lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, + max_length=500, + ), + ), + migrations.AddConstraint( + model_name='learningpackage', + constraint=models.UniqueConstraint( + fields=['package_ref'], + name='oel_publishing_lp_uniq_key', + ), + ), + ] diff --git a/src/openedx_content/migrations/0012_rename_componentversionmedia_key_to_path.py b/src/openedx_content/migrations/0012_rename_componentversionmedia_key_to_path.py new file mode 100644 index 000000000..ee44550df --- /dev/null +++ b/src/openedx_content/migrations/0012_rename_componentversionmedia_key_to_path.py @@ -0,0 +1,46 @@ +""" +Rename ComponentVersionMedia.key -> ComponentVersionMedia.path. + +The field previously had db_column='_key'; this migration also renames the +underlying DB column to match the new field name. +""" +from django.db import migrations, models + +import openedx_django_lib.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('openedx_content', '0011_rename_entity_key_and_package_key_to_refs'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='componentversionmedia', + name='oel_cvcontent_uniq_cv_key', + ), + migrations.RenameField( + model_name='componentversionmedia', + old_name='key', + new_name='path', + ), + # RenameField only changes the Django field name; the DB column is still + # '_key' (set via db_column). AlterField drops db_column, so Django sees + # old column='_key' vs new column='path' and renames it. + migrations.AlterField( + model_name='componentversionmedia', + name='path', + field=openedx_django_lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, + max_length=500, + ), + ), + migrations.AddConstraint( + model_name='componentversionmedia', + constraint=models.UniqueConstraint( + fields=['component_version', 'path'], + name='oel_cvcontent_uniq_cv_key', + ), + ), + ] diff --git a/src/openedx_django_lib/fields.py b/src/openedx_django_lib/fields.py index 44bcb2a23..20f7a0125 100644 --- a/src/openedx_django_lib/fields.py +++ b/src/openedx_django_lib/fields.py @@ -12,10 +12,14 @@ from __future__ import annotations import hashlib +import re import uuid from typing import Any +from django.core.validators import RegexValidator from django.db import models +from django.db.models.lookups import Regex +from django.utils.translation import gettext_lazy as _ from .collations import MultiCollationMixin # Re-export these fields which are in a separate file so we can use .pyi type stubs: @@ -114,16 +118,67 @@ def immutable_uuid_field() -> models.UUIDField: ) -def key_field(**kwargs) -> MultiCollationCharField: +# Alphanumeric, hyphens, underscores, periods +CODE_REGEX = re.compile(r"^[a-zA-Z0-9_.-]+\Z") + + +_CODE_VIOLATION_MSG = _( + 'Enter a valid "code name" consisting of letters, numbers, underscores, hyphens, or periods.' +) + + +def code_field(**kwargs) -> MultiCollationCharField: + """ + Field to hold a 'code', i.e. a slug-like local identifier. + + Use together with :func:`code_field_check` to enforce the same regex at + the database level via a ``CheckConstraint``. + """ + return case_sensitive_char_field( + max_length=255, + blank=False, + validators=[ + RegexValidator( + CODE_REGEX, + # Translators: "letters" means latin letters: a-z and A-Z. + _CODE_VIOLATION_MSG, + "invalid", + ), + ], + **kwargs, + ) + + +def code_field_check(field_name: str, *, name: str) -> models.CheckConstraint: + """ + Return a ``CheckConstraint`` that enforces :data:`CODE_REGEX` at the DB level. + + Django validators (used by :func:`code_field`) are not called on ``.save()`` + or ``.update()``. Adding this constraint ensures the regex is also enforced + by the database itself, and Django will additionally run it as a Python-level + validator automatically. + + Usage:: + + class Meta: + constraints = [ + code_field_check("my_code_field", name="myapp_mymodel_my_code_field_regex"), + ] """ - Externally created Identifier fields. + return models.CheckConstraint( + condition=Regex(models.F(field_name), CODE_REGEX.pattern), + name=name, + violation_error_message=_CODE_VIOLATION_MSG, + ) - These will often be local to a particular scope, like within a - LearningPackage. It's up to the application as to whether they're - semantically meaningful or look more machine-generated. - Other apps should *not* make references to these values directly, since - these values may in theory change (even if this is rare in practice). +def ref_field(**kwargs) -> MultiCollationCharField: + """ + Opaque reference string fields. + + These hold externally-created identifiers that are local to a particular + scope, like within a LearningPackage. Consumers must treat the value as + an atomic string and must never parse or reconstruct it. """ return case_sensitive_char_field(max_length=500, blank=False, **kwargs) diff --git a/src/openedx_tagging/models/base.py b/src/openedx_tagging/models/base.py index 000d4d338..7957a45df 100644 --- a/src/openedx_tagging/models/base.py +++ b/src/openedx_tagging/models/base.py @@ -421,7 +421,7 @@ def copy(self, taxonomy: Taxonomy) -> Taxonomy: return self - def get_filtered_tags( # pylint: disable=too-many-positional-arguments + def get_filtered_tags( self, depth: int | None = None, parent_tag_value: str | None = None, diff --git a/tests/openedx_content/applets/backup_restore/test_backup.py b/tests/openedx_content/applets/backup_restore/test_backup.py index efff635de..af7f94d0a 100644 --- a/tests/openedx_content/applets/backup_restore/test_backup.py +++ b/tests/openedx_content/applets/backup_restore/test_backup.py @@ -51,7 +51,7 @@ def setUpTestData(cls): # Create a Learning Package for the test cls.learning_package = api.create_learning_package( - key="ComponentTestCase-test-key", + package_ref="ComponentTestCase-test-key", title="Components Test Case Learning Package", description="This is a test learning package for components.", ) @@ -69,7 +69,7 @@ def setUpTestData(cls): cls.published_component, _ = api.create_component_and_version( cls.learning_package.id, cls.problem_type, - local_key="my_published_example", + component_code="my_published_example", title="My published problem", created=cls.now, created_by=cls.user.id, @@ -80,7 +80,7 @@ def setUpTestData(cls): cls.published_component2, _ = api.create_component_and_version( cls.learning_package.id, cls.problem_type, - local_key="My_published_example", + component_code="My_published_example", title="My published problem 2", created=cls.now, created_by=cls.user.id, @@ -109,14 +109,14 @@ def setUpTestData(cls): api.create_component_version_media( new_problem_version.pk, new_txt_media.pk, - key="hello.txt", + path="hello.txt", ) # Create a Draft component, one in each learning package cls.draft_component, _ = api.create_component_and_version( cls.learning_package.id, cls.html_type, - local_key="my_draft_example", + component_code="my_draft_example", title="My draft html", created=cls.now, created_by=cls.user.id, @@ -138,7 +138,7 @@ def setUpTestData(cls): api.create_component_version_media( new_html_version.pk, cls.html_asset_media.id, - key="static/other/subdirectory/hello.html", + path="static/other/subdirectory/hello.html", ) components = api.get_publishable_entities(cls.learning_package) @@ -146,7 +146,7 @@ def setUpTestData(cls): cls.collection = api.create_collection( cls.learning_package.id, - key="COL1", + collection_code="COL1", created_by=cls.user.id, title="Collection 1", description="Description of Collection 1", @@ -154,15 +154,15 @@ def setUpTestData(cls): api.add_to_collection( cls.learning_package.id, - cls.collection.key, + cls.collection.collection_code, components ) api.create_container( learning_package_id=cls.learning_package.id, - key="unit-1", created=cls.now, created_by=cls.user.id, + container_code="unit-1", container_cls=Unit, ) @@ -216,8 +216,8 @@ def check_zip_file_structure(self, zip_path: Path): self.assertIn(expected_path, zip_name_list) def test_lp_dump_command(self): - lp_key = self.learning_package.key - file_name = f"{lp_key}.zip" + package_ref = self.learning_package.package_ref + file_name = f"{package_ref}.zip" try: out = StringIO() @@ -225,7 +225,7 @@ def test_lp_dump_command(self): # Call the management command to dump the learning package call_command( - "lp_dump", lp_key, file_name, username=self.user.username, origin_server=origin_server, stdout=out + "lp_dump", package_ref, file_name, username=self.user.username, origin_server=origin_server, stdout=out ) # Check that the zip file was created @@ -242,7 +242,8 @@ def test_lp_dump_command(self): Path("package.toml"), [ '[learning_package]', - f'key = "{self.learning_package.key}"', + f'package_ref = "{self.learning_package.package_ref}"', + f'key = "{self.learning_package.package_ref}"', f'title = "{self.learning_package.title}"', f'description = "{self.learning_package.description}"', '[meta]', @@ -278,7 +279,7 @@ def test_lp_dump_command(self): self.check_toml_file(zip_path, Path(file_path), expected_content) # Check the output message - message = f'{lp_key} written to {file_name}' + message = f'{package_ref} written to {file_name}' self.assertIn(message, out.getvalue()) except Exception as e: # pylint: disable=broad-exception-caught self.fail(f"lp_dump command failed with error: {e}") @@ -289,11 +290,11 @@ def test_lp_dump_command(self): def test_dump_nonexistent_learning_package(self): out = StringIO() - lp_key = "nonexistent_lp" - file_name = f"{lp_key}.zip" + package_ref = "nonexistent_lp" + file_name = f"{package_ref}.zip" with self.assertRaises(CommandError): # Attempt to dump a learning package that does not exist - call_command("lp_dump", lp_key, file_name, stdout=out) + call_command("lp_dump", package_ref, file_name, stdout=out) self.assertIn("Learning package 'nonexistent_lp' does not exist", out.getvalue()) def test_queries_n_plus_problem(self): @@ -315,7 +316,7 @@ def test_queries_n_plus_problem(self): api.create_component_and_version( self.learning_package.id, self.problem_type, - local_key="my_published_example2", + component_code="my_published_example2", title="My published problem 2", created=self.now, created_by=self.user.id, diff --git a/tests/openedx_content/applets/backup_restore/test_restore.py b/tests/openedx_content/applets/backup_restore/test_restore.py index 0116731c4..5af41224d 100644 --- a/tests/openedx_content/applets/backup_restore/test_restore.py +++ b/tests/openedx_content/applets/backup_restore/test_restore.py @@ -8,7 +8,12 @@ from django.core.management import call_command from django.test import TestCase -from openedx_content.applets.backup_restore.zipper import LearningPackageUnzipper, generate_staged_lp_key +from openedx_content.applets.backup_restore.serializers import ( + ComponentSerializer, + ContainerSerializer, + EntitySerializer, +) +from openedx_content.applets.backup_restore.zipper import LearningPackageUnzipper, generate_staged_package_ref from openedx_content.applets.collections import api as collections_api from openedx_content.applets.components import api as components_api from openedx_content.applets.containers import api as containers_api @@ -25,7 +30,7 @@ def setUp(self): super().setUp() self.fixtures_folder = os.path.join(os.path.dirname(__file__), "fixtures/library_backup") self.zip_file = folder_to_inmemory_zip(self.fixtures_folder) - self.lp_key = "lib:WGU:LIB_C001" + self.package_ref = "lib:WGU:LIB_C001" self.user = User.objects.create_user(username='lp_user', password='12345') @@ -42,14 +47,14 @@ def test_restore_command(self, mock_load_learning_package): # You can pass any dummy path, since load_learning_package is mocked call_command("lp_load", "dummy.zip", "lp_user", stdout=out) - lp = self.verify_lp(restore_result["lp_restored_data"]["key"]) + lp = self.verify_lp(restore_result["lp_restored_data"]["package_ref"]) self.verify_containers(lp) self.verify_components(lp) self.verify_collections(lp) - def verify_lp(self, key): + def verify_lp(self, package_ref): """Verify the learning package was restored correctly.""" - lp = publishing_api.LearningPackage.objects.filter(key=key).first() + lp = publishing_api.LearningPackage.objects.filter(package_ref=package_ref).first() assert lp is not None, "Learning package was not restored." assert lp.title == "Library test" assert lp.description == "" @@ -61,26 +66,26 @@ def verify_containers(self, lp): expected_container_keys = ["unit1-b7eafb", "subsection1-48afa3", "section1-8ca126"] for container in container_qs: - assert container.key in expected_container_keys + assert container.entity_ref in expected_container_keys draft_version = publishing_api.get_draft_version(container.publishable_entity.id) published_version = publishing_api.get_published_version(container.publishable_entity.id) assert container.created_by is not None assert container.created_by.username == "lp_user" - if container.key == "unit1-b7eafb": + if container.entity_ref == "unit1-b7eafb": assert containers_api.get_container_type_code_of(container) == "unit" assert draft_version is not None assert draft_version.version_num == 2 assert draft_version.created_by is not None assert draft_version.created_by.username == "lp_user" assert published_version is None - elif container.key == "subsection1-48afa3": + elif container.entity_ref == "subsection1-48afa3": assert containers_api.get_container_type_code_of(container) == "subsection" assert draft_version is not None assert draft_version.version_num == 2 assert draft_version.created_by is not None assert draft_version.created_by.username == "lp_user" assert published_version is None - elif container.key == "section1-8ca126": + elif container.entity_ref == "section1-8ca126": assert containers_api.get_container_type_code_of(container) == "section" assert draft_version is not None assert draft_version.version_num == 2 @@ -88,7 +93,7 @@ def verify_containers(self, lp): assert draft_version.created_by.username == "lp_user" assert published_version is None else: - assert False, f"Unexpected container key: {container.key}" + assert False, f"Unexpected container key: {container.entity_ref}" def verify_components(self, lp): # pylint: disable=too-many-statements @@ -104,12 +109,12 @@ def verify_components(self, lp): "xblock.v1:html:c22b9f97-f1e9-4e8f-87f0-d5a3c26083e2" ] for component in component_qs: - assert component.key in expected_component_keys + assert component.entity_ref in expected_component_keys draft_version = publishing_api.get_draft_version(component.publishable_entity.id) published_version = publishing_api.get_published_version(component.publishable_entity.id) assert component.created_by is not None assert component.created_by.username == "lp_user" - if component.key == "xblock.v1:drag-and-drop-v2:4d1b2fac-8b30-42fb-872d-6b10ab580b27": + if component.entity_ref == "xblock.v1:drag-and-drop-v2:4d1b2fac-8b30-42fb-872d-6b10ab580b27": assert component.component_type.name == "drag-and-drop-v2" assert component.component_type.namespace == "xblock.v1" assert draft_version is not None @@ -124,7 +129,7 @@ def verify_components(self, lp): assert " None: cls.learning_package = api.create_learning_package( - key="ComponentTestCase-test-key", + package_ref="ComponentTestCase-test-key", title="Components Test Case Learning Package", ) cls.learning_package_2 = api.create_learning_package( - key="ComponentTestCase-test-key-2", + package_ref="ComponentTestCase-test-key-2", title="Components Test Case another Learning Package", ) cls.now = datetime(2024, 8, 5, tzinfo=timezone.utc) @@ -64,35 +64,35 @@ def setUpTestData(cls) -> None: super().setUpTestData() cls.collection1 = api.create_collection( cls.learning_package.id, - key="COL1", + collection_code="COL1", created_by=None, title="Collection 1", description="Description of Collection 1", ) cls.collection2 = api.create_collection( cls.learning_package.id, - key="COL2", + collection_code="COL2", created_by=None, title="Collection 2", description="Description of Collection 2", ) cls.collection3 = api.create_collection( cls.learning_package.id, - key="COL3", + collection_code="COL3", created_by=None, title="Collection 3", description="Description of Collection 3", ) cls.another_library_collection = api.create_collection( cls.learning_package_2.id, - key="another_library", + collection_code="another_library", created_by=None, title="Collection 4", description="Description of Collection 4", ) cls.disabled_collection = api.create_collection( cls.learning_package.id, - key="disabled_collection", + collection_code="disabled_collection", created_by=None, title="Disabled Collection", description="Description of Disabled Collection", @@ -123,7 +123,7 @@ def test_get_collection_wrong_learning_package(self): Test getting a collection that doesn't exist in the requested learning package. """ with self.assertRaises(ObjectDoesNotExist): - api.get_collection(self.learning_package.id, self.another_library_collection.key) + api.get_collection(self.learning_package.id, self.another_library_collection.collection_code) def test_get_collections(self): """ @@ -191,14 +191,14 @@ def test_create_collection(self): with freeze_time(created_time): collection = api.create_collection( self.learning_package.id, - key='MYCOL', + collection_code='MYCOL', title="My Collection", created_by=user.id, description="This is my collection", ) assert collection.title == "My Collection" - assert collection.key == "MYCOL" + assert collection.collection_code == "MYCOL" assert collection.description == "This is my collection" assert collection.enabled assert collection.created == created_time @@ -211,15 +211,35 @@ def test_create_collection_without_description(self): """ collection = api.create_collection( self.learning_package.id, - key='MYCOL', + collection_code='MYCOL', created_by=None, title="My Collection", ) assert collection.title == "My Collection" - assert collection.key == "MYCOL" + assert collection.collection_code == "MYCOL" assert collection.description == "" assert collection.enabled + def test_create_collection_invalid_code(self): + """ + collection_code must only contain alphanumerics, hyphens, underscores, and periods. + """ + invalid_codes = [ + "has space", + "has@symbol", + "has/slash", + "has#hash", + ] + for code in invalid_codes: + with self.subTest(code=code): + with self.assertRaises(ValidationError): + api.create_collection( + self.learning_package.id, + collection_code=code, + title="Test", + created_by=None, + ) + class CollectionEntitiesTestCase(CollectionsTestCase): """ @@ -249,9 +269,9 @@ def setUpTestData(cls) -> None: created_time = datetime(2025, 4, 1, tzinfo=timezone.utc) cls.draft_unit = api.create_container( learning_package_id=cls.learning_package.id, - key="unit-1", created=created_time, created_by=cls.user.id, + container_code="unit-1", container_cls=Unit, ) @@ -259,7 +279,7 @@ def setUpTestData(cls) -> None: cls.published_component, _ = api.create_component_and_version( cls.learning_package.id, cls.problem_type, - local_key="my_published_example", + component_code="my_published_example", title="My published problem", created=cls.now, created_by=cls.user.id, @@ -274,7 +294,7 @@ def setUpTestData(cls) -> None: cls.draft_component, _ = api.create_component_and_version( cls.learning_package.id, cls.html_type, - local_key="my_draft_example", + component_code="my_draft_example", title="My draft html", created=cls.now, created_by=cls.user.id, @@ -283,16 +303,16 @@ def setUpTestData(cls) -> None: # Add some shared components to the collections cls.collection1 = api.add_to_collection( cls.learning_package.id, - key=cls.collection1.key, - entities_qset=PublishableEntity.objects.filter(pk__in=[ + collection_code=cls.collection1.collection_code, + entities_qset=PublishableEntity.objects.filter(id__in=[ cls.published_component.id, ]), created_by=cls.user.id, ) cls.collection2 = api.add_to_collection( cls.learning_package.id, - key=cls.collection2.key, - entities_qset=PublishableEntity.objects.filter(pk__in=[ + collection_code=cls.collection2.collection_code, + entities_qset=PublishableEntity.objects.filter(id__in=[ cls.published_component.id, cls.draft_component.id, cls.draft_unit.id, @@ -300,8 +320,8 @@ def setUpTestData(cls) -> None: ) cls.disabled_collection = api.add_to_collection( cls.learning_package.id, - key=cls.disabled_collection.key, - entities_qset=PublishableEntity.objects.filter(pk__in=[ + collection_code=cls.disabled_collection.collection_code, + entities_qset=PublishableEntity.objects.filter(id__in=[ cls.published_component.id, ]), ) @@ -335,8 +355,8 @@ def test_add_to_collection(self): with freeze_time(modified_time): self.collection1 = api.add_to_collection( self.learning_package.id, - self.collection1.key, - PublishableEntity.objects.filter(pk__in=[ + self.collection1.collection_code, + PublishableEntity.objects.filter(id__in=[ self.draft_component.id, self.draft_unit.id, ]), @@ -360,8 +380,8 @@ def test_add_to_collection_again(self): with freeze_time(modified_time): self.collection2 = api.add_to_collection( self.learning_package.id, - self.collection2.key, - PublishableEntity.objects.filter(pk__in=[ + self.collection2.collection_code, + PublishableEntity.objects.filter(id__in=[ self.published_component.id, ]), ) @@ -380,8 +400,8 @@ def test_add_to_collection_wrong_learning_package(self): with self.assertRaises(ValidationError): api.add_to_collection( self.learning_package_2.id, - self.another_library_collection.key, - PublishableEntity.objects.filter(pk__in=[ + self.another_library_collection.collection_code, + PublishableEntity.objects.filter(id__in=[ self.published_component.id, ]), ) @@ -396,8 +416,8 @@ def test_remove_from_collection(self): with freeze_time(modified_time): self.collection2 = api.remove_from_collection( self.learning_package.id, - self.collection2.key, - PublishableEntity.objects.filter(pk__in=[ + self.collection2.collection_code, + PublishableEntity.objects.filter(id__in=[ self.published_component.id, self.draft_unit.id, ]), @@ -414,7 +434,7 @@ def test_get_entity_collections(self): """ collections = api.get_entity_collections( self.learning_package.id, - self.published_component.publishable_entity.key, + self.published_component.publishable_entity.entity_ref, ) assert list(collections) == [ self.collection1, @@ -424,46 +444,46 @@ def test_get_entity_collections(self): def test_get_collection_components(self): assert list(api.get_collection_components( self.learning_package.id, - self.collection1.key, + self.collection1.collection_code, )) == [self.published_component] assert list(api.get_collection_components( self.learning_package.id, - self.collection2.key, + self.collection2.collection_code, )) == [self.published_component, self.draft_component] assert not list(api.get_collection_components( self.learning_package.id, - self.collection3.key, + self.collection3.collection_code, )) assert not list(api.get_collection_components( self.learning_package.id, - self.another_library_collection.key, + self.another_library_collection.collection_code, )) def test_get_collection_containers(self): """ Test using `get_collection_entities()` to get containers """ - def get_collection_containers(learning_package_id: LearningPackage.ID, collection_key: str): + def get_collection_containers(learning_package_id: LearningPackage.ID, collection_code: str): return ( pe.container for pe in - api.get_collection_entities(learning_package_id, collection_key).exclude(container=None) + api.get_collection_entities(learning_package_id, collection_code).exclude(container=None) ) assert not list(get_collection_containers( self.learning_package.id, - self.collection1.key, + self.collection1.collection_code, )) assert list(get_collection_containers( self.learning_package.id, - self.collection2.key, + self.collection2.collection_code, )) == [self.draft_unit.container] assert not list(get_collection_containers( self.learning_package.id, - self.collection3.key, + self.collection3.collection_code, )) assert not list(get_collection_containers( self.learning_package.id, - self.another_library_collection.key, + self.another_library_collection.collection_code, )) @@ -481,7 +501,7 @@ def setUpTestData(cls) -> None: super().setUpTestData() cls.collection = api.create_collection( cls.learning_package.id, - key="MYCOL", + collection_code="MYCOL", title="Collection", created_by=None, description="Description of Collection", @@ -495,7 +515,7 @@ def test_update_collection(self): with freeze_time(modified_time): collection = api.update_collection( self.learning_package.id, - key=self.collection.key, + collection_code=self.collection.collection_code, title="New Title", description="", ) @@ -511,17 +531,19 @@ def test_update_collection_partial(self): """ collection = api.update_collection( self.learning_package.id, - key=self.collection.key, + collection_code=self.collection.collection_code, title="New Title", ) assert collection.title == "New Title" assert collection.description == self.collection.description # unchanged - assert f"{collection}" == f" (lp:{self.learning_package.id} {self.collection.key}:New Title)" + assert f"{collection}" == ( + f" (lp:{self.learning_package.id} {self.collection.collection_code}:New Title)" + ) collection = api.update_collection( self.learning_package.id, - key=self.collection.key, + collection_code=self.collection.collection_code, description="New description", ) @@ -536,7 +558,7 @@ def test_update_collection_empty(self): with freeze_time(modified_time): collection = api.update_collection( self.learning_package.id, - key=self.collection.key, + collection_code=self.collection.collection_code, ) assert collection.title == self.collection.title # unchanged @@ -550,7 +572,7 @@ def test_update_collection_not_found(self): with self.assertRaises(ObjectDoesNotExist): api.update_collection( self.learning_package.id, - key="12345", + collection_code="12345", title="New Title", ) @@ -568,13 +590,13 @@ def test_soft_delete(self): with freeze_time(modified_time): collection = api.delete_collection( self.learning_package.id, - key=self.collection2.key, + collection_code=self.collection2.collection_code, ) # Collection was disabled and still exists in the database assert not collection.enabled assert collection.modified == modified_time - assert collection == api.get_collection(self.learning_package.id, collection.key) + assert collection == api.get_collection(self.learning_package.id, collection.collection_code) # ...and the collection's entities remain intact. assert list(collection.entities.all()) == [ self.draft_unit.publishable_entity, @@ -590,7 +612,7 @@ def test_delete(self): with freeze_time(modified_time): collection = api.delete_collection( self.learning_package.id, - key=self.collection2.key, + collection_code=self.collection2.collection_code, hard_delete=True, ) @@ -598,15 +620,15 @@ def test_delete(self): assert collection.enabled assert not collection.id with self.assertRaises(ObjectDoesNotExist): - api.get_collection(self.learning_package.id, collection.key) + api.get_collection(self.learning_package.id, collection.collection_code) # ...and the entities have been removed from this collection assert list(api.get_entity_collections( self.learning_package.id, - self.published_component.publishable_entity.key, + self.published_component.publishable_entity.entity_ref, )) == [self.collection1] assert not list(api.get_entity_collections( self.learning_package.id, - self.draft_component.publishable_entity.key, + self.draft_component.publishable_entity.entity_ref, )) def test_restore(self): @@ -615,20 +637,20 @@ def test_restore(self): """ collection = api.delete_collection( self.learning_package.id, - key=self.collection2.key, + collection_code=self.collection2.collection_code, ) modified_time = datetime(2024, 8, 8, tzinfo=timezone.utc) with freeze_time(modified_time): collection = api.restore_collection( self.learning_package.id, - key=self.collection2.key, + collection_code=self.collection2.collection_code, ) # Collection was enabled and still exists in the database assert collection.enabled assert collection.modified == modified_time - assert collection == api.get_collection(self.learning_package.id, collection.key) + assert collection == api.get_collection(self.learning_package.id, collection.collection_code) # ...and the collection's entities remain intact. assert list(collection.entities.all()) == [ self.draft_unit.publishable_entity, @@ -714,12 +736,12 @@ def test_set_collection_wrong_learning_package(self): We cannot set collections with a different learning package than the component. """ learning_package_3 = api.create_learning_package( - key="ComponentTestCase-test-key-3", + package_ref="ComponentTestCase-test-key-3", title="Components Test Case Learning Package-3", ) collection = api.create_collection( learning_package_3.id, - key="MYCOL", + collection_code="MYCOL", title="My Collection", created_by=None, description="Description of Collection", diff --git a/tests/openedx_content/applets/components/test_api.py b/tests/openedx_content/applets/components/test_api.py index cbaa889b2..b6960a226 100644 --- a/tests/openedx_content/applets/components/test_api.py +++ b/tests/openedx_content/applets/components/test_api.py @@ -35,7 +35,7 @@ class ComponentTestCase(TestCase): @classmethod def setUpTestData(cls) -> None: cls.learning_package = publishing_api.create_learning_package( - key="ComponentTestCase-test-key", + package_ref="ComponentTestCase-test-key", title="Components Test Case Learning Package", ) cls.now = datetime(2023, 5, 8, tzinfo=timezone.utc) @@ -54,14 +54,14 @@ def publish_component(self, component: Component): ), ) - def create_component(self, *, title: str = "Test Component", key: str = "component:1") -> tuple[ + def create_component(self, *, title: str = "Test Component", component_code: str = "component_1") -> tuple[ Component, ComponentVersion ]: """ Helper method to quickly create a component """ return components_api.create_component_and_version( self.learning_package.id, component_type=self.problem_type, - local_key=key, + component_code=component_code, title=title, created=self.now, created_by=None, @@ -86,7 +86,7 @@ def test_component_num_queries(self) -> None: component, _version = components_api.create_component_and_version( self.learning_package.id, component_type=self.problem_type, - local_key="Query Counting", + component_code="Query_Counting", title="Querying Counting Problem", created=self.now, created_by=None, @@ -131,7 +131,7 @@ def setUpTestData(cls) -> None: cls.published_problem, _version = components_api.create_component_and_version( cls.learning_package.id, component_type=v2_problem_type, - local_key="pp_lk", + component_code="pp_lk", title="Published Problem", created=cls.now, created_by=None, @@ -139,7 +139,7 @@ def setUpTestData(cls) -> None: cls.published_html, _version = components_api.create_component_and_version( cls.learning_package.id, component_type=cls.html_type, - local_key="ph_lk", + component_code="ph_lk", title="Published HTML", created=cls.now, created_by=None, @@ -153,7 +153,7 @@ def setUpTestData(cls) -> None: cls.unpublished_problem, _version = components_api.create_component_and_version( cls.learning_package.id, component_type=v2_problem_type, - local_key="upp_lk", + component_code="upp_lk", title="Unpublished Problem", created=cls.now, created_by=None, @@ -161,7 +161,7 @@ def setUpTestData(cls) -> None: cls.unpublished_html, _version = components_api.create_component_and_version( cls.learning_package.id, component_type=cls.html_type, - local_key="uph_lk", + component_code="uph_lk", title="Unpublished HTML", created=cls.now, created_by=None, @@ -172,7 +172,7 @@ def setUpTestData(cls) -> None: cls.deleted_video, _version = components_api.create_component_and_version( cls.learning_package.id, component_type=cls.video_type, - local_key="dv_lk", + component_code="dv_lk", title="Deleted Video", created=cls.now, created_by=None, @@ -324,14 +324,14 @@ def setUpTestData(cls) -> None: cls.problem = components_api.create_component( cls.learning_package.id, component_type=cls.problem_type, - local_key='my_component', + component_code='my_component', created=cls.now, created_by=None, ) cls.html = components_api.create_component( cls.learning_package.id, component_type=cls.html_type, - local_key='my_component', + component_code='my_component', created=cls.now, created_by=None, can_stand_alone=False, @@ -342,47 +342,47 @@ def test_simple_get(self): with self.assertRaises(ObjectDoesNotExist): components_api.get_component(-1) - def test_publishing_entity_key_convention(self): - """Our mapping convention is {namespace}:{component_type}:{local_key}""" - assert self.problem.key == "xblock.v1:problem:my_component" + def test_publishing_entity_ref_convention(self): + """entity_ref convention: {namespace}:{component_type}:{component_code}""" + assert self.problem.entity_ref == "xblock.v1:problem:my_component" def test_stand_alone_flag(self): """Check if can_stand_alone flag is set""" - component = components_api.get_component_by_key( + component = components_api.get_component_by_code( self.learning_package.id, namespace='xblock.v1', type_name='html', - local_key='my_component', + component_code='my_component', ) assert not component.publishable_entity.can_stand_alone - def test_get_by_key(self): - assert self.html == components_api.get_component_by_key( + def test_get_by_code(self): + assert self.html == components_api.get_component_by_code( self.learning_package.id, namespace='xblock.v1', type_name='html', - local_key='my_component', + component_code='my_component', ) with self.assertRaises(ObjectDoesNotExist): - components_api.get_component_by_key( + components_api.get_component_by_code( self.learning_package.id, namespace='xblock.v1', type_name='video', # 'video' doesn't match anything we have - local_key='my_component', + component_code='my_component', ) - def test_exists_by_key(self): - assert components_api.component_exists_by_key( + def test_exists_by_code(self): + assert components_api.component_exists_by_code( self.learning_package.id, namespace='xblock.v1', type_name='problem', - local_key='my_component', + component_code='my_component', ) - assert not components_api.component_exists_by_key( + assert not components_api.component_exists_by_code( self.learning_package.id, namespace='xblock.v1', type_name='problem', - local_key='not_my_component', + component_code='not_my_component', ) @@ -399,7 +399,7 @@ def setUpTestData(cls) -> None: cls.problem = components_api.create_component( cls.learning_package.id, component_type=cls.problem_type, - local_key='my_component', + component_code='my_component', created=cls.now, created_by=None, ) @@ -422,7 +422,7 @@ def test_add(self): components_api.create_component_version_media( new_version.pk, new_media.pk, - key="my/path/to/hello.txt", + path="my/path/to/hello.txt", ) # re-fetch from the database to check to see if we wrote it correctly new_version = components_api.get_component(self.problem.id) \ @@ -430,7 +430,7 @@ def test_add(self): .get(publishable_entity_version__version_num=1) assert ( new_media == - new_version.media.get(componentversionmedia__key="my/path/to/hello.txt") + new_version.media.get(componentversionmedia__path="my/path/to/hello.txt") ) # Write the same content again, but to an absolute path (should auto- @@ -438,14 +438,14 @@ def test_add(self): components_api.create_component_version_media( new_version.pk, new_media.pk, - key="//nested/path/hello.txt", + path="//nested/path/hello.txt", ) new_version = components_api.get_component(self.problem.id) \ .versions \ .get(publishable_entity_version__version_num=1) assert ( new_media == - new_version.media.get(componentversionmedia__key="nested/path/hello.txt") + new_version.media.get(componentversionmedia__path="nested/path/hello.txt") ) def test_bytes_content(self): @@ -461,8 +461,8 @@ def test_bytes_content(self): created=self.now, ) - content_txt = version_1.media.get(componentversionmedia__key="raw.txt") - content_raw_txt = version_1.media.get(componentversionmedia__key="no_ext") + content_txt = version_1.media.get(componentversionmedia__path="raw.txt") + content_raw_txt = version_1.media.get(componentversionmedia__path="no_ext") assert content_txt.size == len(bytes_media) assert str(content_txt.media_type) == 'text/plain' @@ -509,12 +509,12 @@ def test_multiple_versions(self): assert ( hello_media == version_1.media - .get(componentversionmedia__key="hello.txt") + .get(componentversionmedia__path="hello.txt") ) assert ( goodbye_media == version_1.media - .get(componentversionmedia__key="goodbye.txt") + .get(componentversionmedia__path="goodbye.txt") ) # This should keep the old value for goodbye.txt, add blank.txt, and set @@ -533,17 +533,17 @@ def test_multiple_versions(self): assert ( blank_media == version_2.media - .get(componentversionmedia__key="hello.txt") + .get(componentversionmedia__path="hello.txt") ) assert ( goodbye_media == version_2.media - .get(componentversionmedia__key="goodbye.txt") + .get(componentversionmedia__path="goodbye.txt") ) assert ( blank_media == version_2.media - .get(componentversionmedia__key="blank.txt") + .get(componentversionmedia__path="blank.txt") ) # Now we're going to set "hello.txt" back to hello_content, but remove @@ -564,7 +564,7 @@ def test_multiple_versions(self): assert ( hello_media == version_3.media - .get(componentversionmedia__key="hello.txt") + .get(componentversionmedia__path="hello.txt") ) def test_create_next_version_forcing_num_version(self): @@ -626,17 +626,17 @@ def test_create_multiple_next_versions_and_diff_content(self): assert ( python_source_asset == version_2_draft.media.get( - componentversionmedia__key="static/profile.webp") + componentversionmedia__path="static/profile.webp") ) assert ( python_source_asset == version_2_draft.media.get( - componentversionmedia__key="static/new_file.webp") + componentversionmedia__path="static/new_file.webp") ) with self.assertRaises(ObjectDoesNotExist): # This file was in the published version, but not in the draft version # since we ignored previous content. - version_2_draft.media.get(componentversionmedia__key="static/background.webp") + version_2_draft.media.get(componentversionmedia__path="static/background.webp") class SetCollectionsTestCase(ComponentTestCase): @@ -659,28 +659,28 @@ def setUpTestData(cls) -> None: cls.published_problem, _ = components_api.create_component_and_version( cls.learning_package.id, component_type=v2_problem_type, - local_key="pp_lk", + component_code="pp_lk", title="Published Problem", created=cls.now, created_by=None, ) cls.collection1 = collection_api.create_collection( cls.learning_package.id, - key="MYCOL1", + collection_code="MYCOL1", title="Collection1", created_by=None, description="Description of Collection 1", ) cls.collection2 = collection_api.create_collection( cls.learning_package.id, - key="MYCOL2", + collection_code="MYCOL2", title="Collection2", created_by=None, description="Description of Collection 2", ) cls.collection3 = collection_api.create_collection( cls.learning_package.id, - key="MYCOL3", + collection_code="MYCOL3", title="Collection3", created_by=None, description="Description of Collection 3", @@ -696,31 +696,19 @@ class TestComponentTypeUtils(TestCase): Test the component type utility functions. """ - def test_get_or_create_component_type_by_entity_key_creates_new(self): - comp_type, local_key = components_api.get_or_create_component_type_by_entity_key( - "video:youtube:abcd1234" - ) + def test_get_or_create_component_type_creates_new(self): + comp_type = components_api.get_or_create_component_type("video", "youtube") assert isinstance(comp_type, ComponentType) assert comp_type.namespace == "video" assert comp_type.name == "youtube" - assert local_key == "abcd1234" assert ComponentType.objects.count() == 1 - def test_get_or_create_component_type_by_entity_key_existing(self): + def test_get_or_create_component_type_existing(self): ComponentType.objects.create(namespace="video", name="youtube") - comp_type, local_key = components_api.get_or_create_component_type_by_entity_key( - "video:youtube:efgh5678" - ) + comp_type = components_api.get_or_create_component_type("video", "youtube") assert comp_type.namespace == "video" assert comp_type.name == "youtube" - assert local_key == "efgh5678" assert ComponentType.objects.count() == 1 - - def test_get_or_create_component_type_by_entity_key_invalid_format(self): - with self.assertRaises(ValueError) as ctx: - components_api.get_or_create_component_type_by_entity_key("not-enough-parts") - - self.assertIn("Invalid entity_key format", str(ctx.exception)) diff --git a/tests/openedx_content/applets/components/test_assets.py b/tests/openedx_content/applets/components/test_assets.py index a9c378681..4de763f57 100644 --- a/tests/openedx_content/applets/components/test_assets.py +++ b/tests/openedx_content/applets/components/test_assets.py @@ -53,13 +53,13 @@ def setUpTestData(cls) -> None: cls.html_media_type = media_api.get_or_create_media_type("text/html") cls.learning_package = publishing_api.create_learning_package( - key="ComponentTestCase-test-key", + package_ref="ComponentTestCase-test-key", title="Components Test Case Learning Package", ) cls.component, cls.component_version = components_api.create_component_and_version( cls.learning_package.id, component_type=cls.problem_type, - local_key="my_problem", + component_code="my_problem", title="My Problem", created=cls.now, created_by=None, @@ -75,7 +75,7 @@ def setUpTestData(cls) -> None: components_api.create_component_version_media( cls.component_version.pk, cls.problem_media.id, - key="block.xml", + path="block.xml", ) # Python source file, stored as a file. This is hypothetical, as we @@ -89,7 +89,7 @@ def setUpTestData(cls) -> None: components_api.create_component_version_media( cls.component_version.pk, cls.python_source_asset.id, - key="src/grader.py", + path="src/grader.py", ) # An HTML file that is student downloadable @@ -102,7 +102,7 @@ def setUpTestData(cls) -> None: components_api.create_component_version_media( cls.component_version.pk, cls.html_asset_media.id, - key="static/hello.html", + path="static/hello.html", ) def test_no_component_version(self): @@ -125,11 +125,11 @@ def _assert_has_component_version_headers(self, headers): Note: The request header values in an HttpResponse will all have been serialized to strings. """ - assert headers["X-Open-edX-Component-Key"] == self.component.key + assert headers["X-Open-edX-Component-Key"] == self.component.entity_ref assert headers["X-Open-edX-Component-Uuid"] == str(self.component.uuid) assert headers["X-Open-edX-Component-Version-Uuid"] == str(self.component_version.uuid) assert headers["X-Open-edX-Component-Version-Num"] == str(self.component_version.version_num) - assert headers["X-Open-edX-Learning-Package-Key"] == self.learning_package.key + assert headers["X-Open-edX-Learning-Package-Key"] == self.learning_package.package_ref assert headers["X-Open-edX-Learning-Package-Uuid"] == str(self.learning_package.uuid) def test_404s_with_component_version_info(self): diff --git a/tests/openedx_content/applets/components/test_models.py b/tests/openedx_content/applets/components/test_models.py index bdcdea725..e029bed17 100644 --- a/tests/openedx_content/applets/components/test_models.py +++ b/tests/openedx_content/applets/components/test_models.py @@ -51,7 +51,7 @@ def test_latest_version(self) -> None: component, component_version = create_component_and_version( self.learning_package.id, component_type=self.problem_type, - local_key="monty_hall", + component_code="monty_hall", title="Monty Hall Problem", created=self.now, created_by=None, @@ -82,7 +82,7 @@ def test_last_publish_log(self): component_with_changes, _ = create_component_and_version( self.learning_package.id, component_type=self.problem_type, - local_key="with_changes", + component_code="with_changes", title="Component with changes v1", created=self.now, created_by=None, @@ -92,7 +92,7 @@ def test_last_publish_log(self): component_with_no_changes, _ = create_component_and_version( self.learning_package.id, component_type=self.problem_type, - local_key="with_no_changes", + component_code="with_no_changes", title="Component with no changes v1", created=self.now, created_by=None, diff --git a/tests/openedx_content/applets/containers/test_api.py b/tests/openedx_content/applets/containers/test_api.py index f40f16cd5..c5c8bdda6 100644 --- a/tests/openedx_content/applets/containers/test_api.py +++ b/tests/openedx_content/applets/containers/test_api.py @@ -92,18 +92,18 @@ def _other_user(django_user_model): @pytest.fixture(name="lp") def _lp() -> LearningPackage: """Get a Learning Package.""" - return publishing_api.create_learning_package(key="containers-test-lp", title="Testing Containers Main LP") + return publishing_api.create_learning_package(package_ref="containers-test-lp", title="Testing Containers Main LP") @pytest.fixture(name="lp2") def _lp2() -> LearningPackage: """Get a Second Learning Package.""" - return publishing_api.create_learning_package(key="containers-test-lp2", title="Testing Containers (📦 2)") + return publishing_api.create_learning_package(package_ref="containers-test-lp2", title="Testing Containers (📦 2)") -def create_test_entity(learning_package: LearningPackage, key: str, title: str) -> TestEntity: +def create_test_entity(learning_package: LearningPackage, ref: str, title: str) -> TestEntity: """Create a TestEntity with a draft version""" - pe = publishing_api.create_publishable_entity(learning_package.id, key, created=now, created_by=None) + pe = publishing_api.create_publishable_entity(learning_package.id, ref, created=now, created_by=None) new_entity = TestEntity.objects.create(publishable_entity=pe) pev = publishing_api.create_publishable_entity_version( new_entity.pk, @@ -119,35 +119,38 @@ def create_test_entity(learning_package: LearningPackage, key: str, title: str) @pytest.fixture(name="child_entity1") def _child_entity1(lp: LearningPackage) -> TestEntity: """An example entity, such as a component""" - return create_test_entity(lp, key="child_entity1", title="Child 1 🌴") + return create_test_entity(lp, ref="child_entity1", title="Child 1 🌴") @pytest.fixture(name="child_entity2") def _child_entity2(lp: LearningPackage) -> TestEntity: """An example entity, such as a component""" - return create_test_entity(lp, key="child_entity2", title="Child 2 🌈") + return create_test_entity(lp, ref="child_entity2", title="Child 2 🌈") @pytest.fixture(name="child_entity3") def _child_entity3(lp: LearningPackage) -> TestEntity: """An example entity, such as a component""" - return create_test_entity(lp, key="child_entity3", title="Child 3 ⛵️") + return create_test_entity(lp, ref="child_entity3", title="Child 3 ⛵️") @pytest.fixture(name="other_lp_child") def _other_lp_child(lp2: LearningPackage) -> TestEntity: """An example entity, such as a component""" - return create_test_entity(lp2, key="other_lp_child", title="Child in other Learning Package 📦") + return create_test_entity(lp2, ref="other_lp_child", title="Child in other Learning Package 📦") def create_test_container( - learning_package: LearningPackage, key: str, entities: containers_api.EntityListInput, title: str = "" + learning_package: LearningPackage, + container_code: str, + entities: containers_api.EntityListInput, + title: str = "", ) -> TestContainer: """Create a TestContainer with a draft version""" container, _version = containers_api.create_container_and_version( learning_package.id, - key=key, - title=title or f"Container ({key})", + container_code=container_code, + title=title or f"Container ({container_code})", entities=entities, container_cls=TestContainer, created=now, @@ -161,7 +164,7 @@ def _parent_of_two(lp: LearningPackage, child_entity1: TestEntity, child_entity2 """An TestContainer with two children""" return create_test_container( lp, - key="parent_of_two", + container_code="parent_of_two", title="Generic Container with Two Unpinned Children", entities=[child_entity1, child_entity2], ) @@ -177,7 +180,7 @@ def _parent_of_three( """An TestContainer with three children, two of which are pinned""" return create_test_container( lp, - key="parent_of_three", + container_code="parent_of_three", title="Generic Container with Two 📌 Pinned Children and One Unpinned", entities=[child_entity3.versioning.draft, child_entity2.versioning.draft, child_entity1], ) @@ -193,7 +196,7 @@ def _parent_of_six( """An TestContainer with six children, two of each entity, with different pinned combinations""" return create_test_container( lp, - key="parent_of_six", + container_code="parent_of_six", title="Generic Container with Two 📌 Pinned Children and One Unpinned", entities=[ # 1: both unpinned, 2: both pinned, and 3: pinned and unpinned @@ -216,7 +219,7 @@ def _grandparent( """An ContainerContainer with two unpinned children""" grandparent, _version = containers_api.create_container_and_version( lp.id, - key="grandparent", + container_code="grandparent", title="Generic Container with Two Unpinned TestContainer children", entities=[parent_of_two, parent_of_three], container_cls=ContainerContainer, @@ -235,7 +238,7 @@ def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: TestEntit # First create a TestContainer, then we'll modify it to simulate it being from an uninstalled plugin container, _ = containers_api.create_container_and_version( lp.id, - key="abandoned-container", + container_code="abandoned-container", title="Abandoned Container 1", entities=[child_entity1], container_cls=TestContainer, @@ -252,7 +255,7 @@ def _other_lp_parent(lp2: LearningPackage, other_lp_child: TestEntity) -> TestCo """An TestContainer with one child""" other_lp_parent, _version = containers_api.create_container_and_version( lp2.id, - key="other_lp_parent", + container_code="other_lp_parent", title="Generic Container with One Unpinned Child Entity", entities=[other_lp_child], container_cls=TestContainer, @@ -302,7 +305,7 @@ def test_create_generic_empty_container(lp: LearningPackage, admin_user) -> None """ container, container_v1 = containers_api.create_container_and_version( lp.id, - key="new-container-1", + container_code="new-container-1", title="Test Container 1", container_cls=TestContainer, created=now, @@ -333,7 +336,7 @@ def test_create_generic_empty_container(lp: LearningPackage, admin_user) -> None assert isinstance(container_v1, TestContainerVersion) assert container.versioning.draft == container_v1 assert container.versioning.published is None - assert container.key == "new-container-1" + assert container.entity_ref == "new-container-1" assert container.versioning.draft.title == "Test Container 1" assert container.created == now assert container.created_by == admin_user @@ -356,10 +359,14 @@ def test_create_container_queries(lp: LearningPackage, child_entity1: TestEntity } # The exact numbers here aren't too important - this is just to alert us if anything significant changes. with django_assert_num_queries(31): - containers_api.create_container_and_version(lp.id, key="c1", **base_args) + containers_api.create_container_and_version( + lp.id, container_code="c1", **base_args + ) # And try with a a container that has children: with django_assert_num_queries(32): - containers_api.create_container_and_version(lp.id, key="c2", **base_args, entities=[child_entity1]) + containers_api.create_container_and_version( + lp.id, container_code="c2", **base_args, entities=[child_entity1] + ) # versioning helpers @@ -417,9 +424,9 @@ def test_create_next_container_version_no_changes(parent_of_two: TestContainer, assert version_2.entity_list_id == original_version.entity_list_id assert version_2.created == v2_date assert version_2.created_by == other_user - assert containers_api.get_container_children_entities_keys( + assert containers_api.get_container_children_entity_refs( original_version - ) == containers_api.get_container_children_entities_keys(version_2) + ) == containers_api.get_container_children_entity_refs(version_2) def test_create_next_container_version_with_changes( @@ -451,7 +458,7 @@ def test_create_next_container_version_with_changes( assert version_5.created_by is None assert version_5.title == "New Title - children reversed" assert version_5.entity_list_id != original_version.entity_list_id - assert containers_api.get_container_children_entities_keys(version_5) == ["child_entity2", "child_entity1"] + assert containers_api.get_container_children_entity_refs(version_5) == ["child_entity2", "child_entity1"] def test_create_next_container_version_with_append( @@ -754,29 +761,29 @@ def test_get_container_version_nonexistent() -> None: containers_api.get_container_version(-500) -# get_container_by_key +# get_container_by_ref -def test_get_container_by_key(lp: LearningPackage, parent_of_two: TestContainer) -> None: +def test_get_container_by_ref(lp: LearningPackage, parent_of_two: TestContainer) -> None: """ - Test getting a specific container by key + Test getting a specific container by entity ref """ - result = containers_api.get_container_by_key(lp.id, parent_of_two.key) + result = containers_api.get_container_by_ref(lp.id, parent_of_two.entity_ref) assert result == parent_of_two.container # The API always returns "Container", not specific subclasses like TestContainer: assert result.__class__ is Container -def test_get_container_by_key_nonexistent(lp: LearningPackage) -> None: +def test_get_container_by_ref_nonexistent(lp: LearningPackage) -> None: """ - Test getting a specific container by key, where the key and/or learning package is invalid + Test getting a specific container by entity ref, where the ref and/or learning package is invalid """ FAKE_ID = cast(LearningPackage.ID, -500) with pytest.raises(LearningPackage.DoesNotExist): - containers_api.get_container_by_key(FAKE_ID, "invalid-key") + containers_api.get_container_by_ref(FAKE_ID, "invalid-ref") with pytest.raises(Container.DoesNotExist): - containers_api.get_container_by_key(lp.id, "invalid-key") + containers_api.get_container_by_ref(lp.id, "invalid-ref") # get_container_subclass @@ -987,7 +994,8 @@ def test_no_publish_parent(parent_of_two: TestContainer, child_entity1: TestEnti Test that publishing an entity does NOT publish changes to its parent containers """ # "child_entity1" is a child of "parent_of_two" - assert child_entity1.key in containers_api.get_container_children_entities_keys(parent_of_two.versioning.draft) + children_refs = containers_api.get_container_children_entity_refs(parent_of_two.versioning.draft) + assert child_entity1.entity_ref in children_refs # Neither are published: assert child_entity1.versioning.published is None assert parent_of_two.versioning.published is None @@ -1145,7 +1153,7 @@ def test_publishing_shared_component(lp: LearningPackage): Everything is "unpinned". """ # 1️⃣ Create the units and publish them: - c1, c2, c3, c4, c5 = [create_test_entity(lp, key=f"C{i}", title=f"Component {i}") for i in range(1, 6)] + c1, c2, c3, c4, c5 = [create_test_entity(lp, ref=f"C{i}", title=f"Component {i}") for i in range(1, 6)] c1_v1 = c1.versioning.draft c3_v1 = c3.versioning.draft c4_v1 = c4.versioning.draft @@ -1154,7 +1162,7 @@ def test_publishing_shared_component(lp: LearningPackage): lp.id, entities=[c1, c2, c3], title="Unit 1", - key="unit:1", + container_code="unit-1", created=now, created_by=None, container_cls=TestContainer, @@ -1163,7 +1171,7 @@ def test_publishing_shared_component(lp: LearningPackage): lp.id, entities=[c2, c4, c5], title="Unit 2", - key="unit:2", + container_code="unit-2", created=now, created_by=None, container_cls=TestContainer, @@ -1228,7 +1236,7 @@ def test_shallow_publish_log( ) -> None: """Simple test of publishing a container plus children and reviewing the publish log""" publish_log = publish_entity(parent_of_two) - assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [ + assert list(publish_log.records.order_by("entity__pk").values_list("entity__entity_ref", flat=True)) == [ # The container and its two children should be the only things published: "child_entity1", "child_entity2", @@ -1247,7 +1255,7 @@ def test_uninstalled_publish( with django_assert_num_queries(50): publish_log = publish_entity(container_of_uninstalled_type) # Nothing else should have been affected by the publish: - assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [ + assert list(publish_log.records.order_by("entity__pk").values_list("entity__entity_ref", flat=True)) == [ "child_entity1", "abandoned-container", ] @@ -1276,7 +1284,7 @@ def test_deep_publish_log( # Create a "great grandparent" container that contains "grandparent" great_grandparent = create_test_container( lp, - key="great_grandparent", + container_code="great_grandparent", title="Great-grandparent container", entities=[grandparent], ) @@ -1285,7 +1293,7 @@ def test_deep_publish_log( with django_assert_num_queries(50): publish_log = publish_entity(container_of_uninstalled_type) # Nothing else should have been affected by the publish: - assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [ + assert list(publish_log.records.order_by("entity__pk").values_list("entity__entity_ref", flat=True)) == [ "child_entity1", "abandoned-container", ] @@ -1293,7 +1301,7 @@ def test_deep_publish_log( # Publish great_grandparent. Should publish the whole tree. with django_assert_num_queries(127): publish_log = publish_entity(great_grandparent) - assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [ + assert list(publish_log.records.order_by("entity__pk").values_list("entity__entity_ref", flat=True)) == [ "child_entity2", "parent_of_two", "parent_of_three", @@ -1401,7 +1409,7 @@ def test_snapshots_of_published_unit(lp: LearningPackage, child_entity1: TestEnt child_entity1_v1 = child_entity1.versioning.draft # At first the container has one child (unpinned): - container = create_test_container(lp, key="c", entities=[child_entity1]) + container = create_test_container(lp, container_code="c", entities=[child_entity1]) modify_entity(child_entity1, title="Component 1 as of checkpoint 1") _, before_publish = containers_api.get_entities_in_container_as_of(container, 0) assert not before_publish # Empty list @@ -1612,21 +1620,21 @@ def test_get_container_children_count_queries( assert containers_api.get_container_children_count(parent_of_six, published=True) == 6 -# get_container_children_entities_keys +# get_container_children_entity_refs -def test_get_container_children_entities_keys(grandparent: ContainerContainer, parent_of_six: TestContainer) -> None: - """Test `get_container_children_entities_keys()`""" +def test_get_container_children_entity_refs(grandparent: ContainerContainer, parent_of_six: TestContainer) -> None: + """Test `get_container_children_entity_refs()`""" - # TODO: is get_container_children_entities_keys() a useful API method? It's not used in edx-platform. + # TODO: is get_container_children_entity_refs() a useful API method? It's not used in edx-platform. - assert containers_api.get_container_children_entities_keys(grandparent.versioning.draft) == [ + assert containers_api.get_container_children_entity_refs(grandparent.versioning.draft) == [ # These are the two children of "grandparent" - see diagram near the top of this file. "parent_of_two", "parent_of_three", ] - assert containers_api.get_container_children_entities_keys(parent_of_six.versioning.draft) == [ + assert containers_api.get_container_children_entity_refs(parent_of_six.versioning.draft) == [ "child_entity3", "child_entity2", "child_entity1", diff --git a/tests/openedx_content/applets/media/test_file_storage.py b/tests/openedx_content/applets/media/test_file_storage.py index 7cf6ea664..a4aec1ba5 100644 --- a/tests/openedx_content/applets/media/test_file_storage.py +++ b/tests/openedx_content/applets/media/test_file_storage.py @@ -29,7 +29,7 @@ def setUp(self) -> None: """ super().setUp() learning_package = publishing_api.create_learning_package( - key="ContentFileStorageTestCase-test-key", + package_ref="ContentFileStorageTestCase-test-key", title="Content File Storage Test Case Learning Package", ) self.html_media_type = media_api.get_or_create_media_type("text/html") diff --git a/tests/openedx_content/applets/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index b39a74ea3..ba8620ec4 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -39,13 +39,13 @@ def test_normal(self) -> None: # Note: we must specify '-> None' to opt in to t created = datetime(2023, 4, 2, 15, 9, 0, tzinfo=timezone.utc) description = "A fun Description!" package = publishing_api.create_learning_package( - key=key, + package_ref=key, title=title, description=description, created=created ) - assert package.key == "my_key" + assert package.package_ref == "my_key" assert package.title == "My Excellent Title with Emoji 🔥" assert package.description == "A fun Description!" assert package.created == created @@ -60,11 +60,11 @@ def test_normal(self) -> None: # Note: we must specify '-> None' to opt in to t # Now test editing the fields. updated_package = publishing_api.update_learning_package( package.id, - key="new_key", + package_ref="new_key", title="new title", description="new description", ) - assert updated_package.key == "new_key" + assert updated_package.package_ref == "new_key" assert updated_package.title == "new title" assert updated_package.description == "new description" assert updated_package.created == created @@ -78,7 +78,7 @@ def test_auto_datetime(self) -> None: title = "My Excellent Title with Emoji 🔥" package = publishing_api.create_learning_package(key, title) - assert package.key == "my_key" + assert package.package_ref == "my_key" assert package.title == "My Excellent Title with Emoji 🔥" # Auto-generated datetime checking... @@ -98,7 +98,7 @@ def test_non_utc_time(self) -> None: """ with pytest.raises(ValidationError) as excinfo: publishing_api.create_learning_package( - key="my_key", + package_ref="my_key", title="A Title", created=datetime(2023, 4, 2) ) @@ -116,7 +116,7 @@ def test_already_exists(self) -> None: with pytest.raises(ValidationError) as excinfo: publishing_api.create_learning_package("my_key", "Duplicate") message_dict = excinfo.value.message_dict - assert "key" in message_dict + assert "package_ref" in message_dict class DraftTestCase(TestCase): @@ -1126,9 +1126,9 @@ def test_parent_child_side_effects(self) -> None: ) container = containers_api.create_container( self.learning_package.id, - "my_container", created=self.now, created_by=None, + container_code="my_container", container_cls=TestContainer, ) container_v1 = containers_api.create_container_version( @@ -1206,9 +1206,9 @@ def test_bulk_parent_child_side_effects(self) -> None: ) container = containers_api.create_container( self.learning_package.id, - "my_container", created=self.now, created_by=None, + container_code="my_container", container_cls=TestContainer, ) container_v1 = containers_api.create_container_version( @@ -1282,16 +1282,16 @@ def test_draft_dependency_multiple_parents(self) -> None: ) unit_1 = containers_api.create_container( self.learning_package.id, - "unit_1", created=self.now, created_by=None, + container_code="unit_1", container_cls=TestContainer, ) unit_2 = containers_api.create_container( self.learning_package.id, - "unit_2", created=self.now, created_by=None, + container_code="unit_2", container_cls=TestContainer, ) for unit in [unit_1, unit_2]: @@ -1344,9 +1344,9 @@ def test_multiple_layers_of_containers(self) -> None: ) unit = containers_api.create_container( self.learning_package.id, - "unit_1", created=self.now, created_by=None, + container_code="unit_1", container_cls=TestContainer, ) containers_api.create_container_version( @@ -1359,9 +1359,9 @@ def test_multiple_layers_of_containers(self) -> None: ) subsection = containers_api.create_container( self.learning_package.id, - "subsection_1", created=self.now, created_by=None, + container_code="subsection_1", container_cls=TestContainer, ) containers_api.create_container_version( @@ -1443,9 +1443,9 @@ def test_publish_all_layers(self) -> None: ) unit = containers_api.create_container( self.learning_package.id, - "unit_1", created=self.now, created_by=None, + container_code="unit_1", container_cls=TestContainer, ) containers_api.create_container_version( @@ -1458,9 +1458,9 @@ def test_publish_all_layers(self) -> None: ) subsection = containers_api.create_container( self.learning_package.id, - "subsection_1", created=self.now, created_by=None, + container_code="subsection_1", container_cls=TestContainer, ) containers_api.create_container_version( @@ -1494,7 +1494,8 @@ def test_direct_field_publishing_container_marks_dependencies_indirect(self) -> created=self.now, created_by=None, ) unit = containers_api.create_container( - self.learning_package.id, "direct_unit", + self.learning_package.id, + container_code="direct_unit", created=self.now, created_by=None, container_cls=TestContainer, ) containers_api.create_container_version( @@ -1527,7 +1528,8 @@ def test_direct_field_unit_no_version_change_still_direct_true(self) -> None: created=self.now, created_by=None, ) unit = containers_api.create_container( - self.learning_package.id, "no_change_unit", + self.learning_package.id, + container_code="no_change_unit", created=self.now, created_by=None, container_cls=TestContainer, ) unit_v1 = containers_api.create_container_version( @@ -1582,7 +1584,8 @@ def test_direct_field_publishing_component_marks_parent_indirect(self) -> None: created=self.now, created_by=None, ) unit = containers_api.create_container( - self.learning_package.id, "leaf_unit", + self.learning_package.id, + container_code="leaf_unit", created=self.now, created_by=None, container_cls=TestContainer, ) containers_api.create_container_version( @@ -1618,7 +1621,8 @@ def test_direct_field_both_selected_both_direct(self) -> None: created=self.now, created_by=None, ) unit = containers_api.create_container( - self.learning_package.id, "both_unit", + self.learning_package.id, + container_code="both_unit", created=self.now, created_by=None, container_cls=TestContainer, ) containers_api.create_container_version( @@ -1642,9 +1646,9 @@ def test_container_next_version(self) -> None: ) container = containers_api.create_container( self.learning_package.id, - "my_container", created=self.now, created_by=None, + container_code="my_container", container_cls=TestContainer, ) assert container.versioning.latest is None diff --git a/tests/openedx_content/applets/sections/test_api.py b/tests/openedx_content/applets/sections/test_api.py index 3e873915a..5df5471ff 100644 --- a/tests/openedx_content/applets/sections/test_api.py +++ b/tests/openedx_content/applets/sections/test_api.py @@ -22,11 +22,11 @@ def setUp(self) -> None: """Create some potential desdendants for use in these tests.""" super().setUp() self.component_1, self.component_1_v1 = self.create_component( - key="component_1", + component_code="component_1", title="Great-grandchild component", ) self.component_2, self.component_2_v1 = self.create_component( - key="component_2", + component_code="component_2", title="Great-grandchild component", ) common_args: dict[str, Any] = { @@ -35,25 +35,25 @@ def setUp(self) -> None: "created_by": None, } self.unit_1, self.unit_1_v1 = content_api.create_unit_and_version( - key="unit_1", + container_code="unit_1", title="Grandchild Unit 1", components=[self.component_1, self.component_2], **common_args, ) self.unit_2, self.unit_2_v1 = content_api.create_unit_and_version( - key="unit_2", + container_code="unit_2", title="Grandchild Unit 2", components=[self.component_2, self.component_1], # Backwards order from Unit 1 **common_args, ) self.subsection_1, self.subsection_1_v1 = content_api.create_subsection_and_version( - key="subsection_1", + container_code="subsection_1", title="Child Subsection 1", units=[self.unit_1, self.unit_2], **common_args, ) self.subsection_2, self.subsection_2_v1 = content_api.create_subsection_and_version( - key="subsection_2", + container_code="subsection_2", title="Child Subsection 2", units=[self.unit_2, self.unit_1], # Backwards order from subsection 1 **common_args, @@ -69,7 +69,7 @@ def create_section_with_subsections( """Helper method to quickly create a section with some subsections""" section, _section_v1 = content_api.create_section_and_version( learning_package_id=self.learning_package.id, - key=key, + container_code=key, title=title, subsections=subsections, created=self.now, @@ -88,7 +88,7 @@ def test_create_empty_section_and_version(self) -> None: """ section, section_version = content_api.create_section_and_version( learning_package_id=self.learning_package.id, - key="section:key", + container_code="section-key", title="Section", created=self.now, created_by=None, diff --git a/tests/openedx_content/applets/subsections/test_api.py b/tests/openedx_content/applets/subsections/test_api.py index e4bc871e2..64ad68c0e 100644 --- a/tests/openedx_content/applets/subsections/test_api.py +++ b/tests/openedx_content/applets/subsections/test_api.py @@ -21,16 +21,16 @@ class SubsectionsTestCase(ComponentTestCase): def setUp(self) -> None: super().setUp() self.component_1, self.component_1_v1 = self.create_component( - key="Query Counting", + component_code="Query_Counting", title="Querying Counting Problem", ) self.component_2, self.component_2_v1 = self.create_component( - key="Query Counting (2)", + component_code="Query_Counting_2", title="Querying Counting Problem (2)", ) self.unit_1, self.unit_1_v1 = content_api.create_unit_and_version( learning_package_id=self.learning_package.id, - key="unit1", + container_code="unit1", title="Unit 1", components=[self.component_1, self.component_2], created=self.now, @@ -47,7 +47,7 @@ def create_subsection_with_units( """Helper method to quickly create a unit with some units""" subsection, _subsection_v1 = content_api.create_subsection_and_version( learning_package_id=self.learning_package.id, - key=key, + container_code=key, title=title, units=units, created=self.now, @@ -66,7 +66,7 @@ def test_create_empty_subsection_and_version(self): """ subsection, subsection_version = content_api.create_subsection_and_version( learning_package_id=self.learning_package.id, - key="subsection:key", + container_code="subsection-key", title="Subsection", created=self.now, created_by=None, @@ -156,7 +156,7 @@ def test_create_subsection_with_invalid_children(self): # Try adding a Component to a Subsection with pytest.raises( ValidationError, - match='The entity "xblock.v1:problem:Query Counting" cannot be added to a "subsection" container.', + match='The entity "xblock.v1:problem:Query_Counting" cannot be added to a "subsection" container.', ) as err: content_api.create_next_subsection_version( subsection, @@ -175,7 +175,7 @@ def test_create_subsection_with_invalid_children(self): # (not just `create_next_subsection_version()`) with pytest.raises( ValidationError, - match='The entity "xblock.v1:problem:Query Counting" cannot be added to a "subsection" container.', + match='The entity "xblock.v1:problem:Query_Counting" cannot be added to a "subsection" container.', ): self.create_subsection_with_units([self.component_1], key="unit:key3", title="Unit 3") diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 6ca67fdf0..d53d3cd3a 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -22,11 +22,11 @@ class UnitsTestCase(ComponentTestCase): def setUp(self) -> None: super().setUp() self.component_1, self.component_1_v1 = self.create_component( - key="Query Counting", + component_code="Query_Counting", title="Querying Counting Problem", ) self.component_2, self.component_2_v1 = self.create_component( - key="Query Counting (2)", + component_code="Query_Counting_2", title="Querying Counting Problem (2)", ) @@ -40,7 +40,7 @@ def create_unit_with_components( """Helper method to quickly create a unit with some components""" unit, _unit_v1 = content_api.create_unit_and_version( learning_package_id=self.learning_package.id, - key=key, + container_code=key, title=title, components=components, created=self.now, @@ -59,7 +59,7 @@ def test_create_empty_unit_and_version(self): """ unit, unit_version = content_api.create_unit_and_version( learning_package_id=self.learning_package.id, - key="unit:key", + container_code="unit-key", title="Unit", created=self.now, created_by=None, @@ -120,9 +120,9 @@ def test_get_unit_other_container_type(self) -> None: """Test `get_unit()` when the provided ID is for a non-Unit container""" other_container = content_api.create_container( self.learning_package.id, - key="test", created=self.now, created_by=None, + container_code="test", container_cls=TestContainer, ) with pytest.raises(Unit.DoesNotExist): diff --git a/tests/openedx_tagging/test_system_defined_models.py b/tests/openedx_tagging/test_system_defined_models.py index dc58e7b8b..5fd8565c9 100644 --- a/tests/openedx_tagging/test_system_defined_models.py +++ b/tests/openedx_tagging/test_system_defined_models.py @@ -42,7 +42,7 @@ def tag_class_model(self): @property def tag_class_value_field(self) -> str: - return "key" + return "package_ref" @property def tag_class_key_field(self) -> str: @@ -87,8 +87,8 @@ def _create_learning_pkg(**kwargs) -> LearningPackage: def setUpClass(cls): super().setUpClass() # Create two learning packages and a taxonomy that can tag any object using learning packages as tags: - cls.learning_pkg_1 = cls._create_learning_pkg(key="p1", title="Learning Package 1") - cls.learning_pkg_2 = cls._create_learning_pkg(key="p2", title="Learning Package 2") + cls.learning_pkg_1 = cls._create_learning_pkg(package_ref="p1", title="Learning Package 1") + cls.learning_pkg_2 = cls._create_learning_pkg(package_ref="p2", title="Learning Package 2") cls.lp_taxonomy = LPTaxonomyTest.objects.create( taxonomy_class=LPTaxonomyTest, name="LearningPackage Taxonomy", @@ -108,11 +108,11 @@ def test_lp_taxonomy_validation(self): Test that the validation methods of the Learning Package Taxonomy are working """ # Create a new LearningPackage - we know no Tag instances will exist for it yet. - valid_lp = self._create_learning_pkg(key="valid-lp", title="New Learning Packacge") + valid_lp = self._create_learning_pkg(package_ref="valid-lp", title="New Learning Packacge") # The taxonomy can validate tags by value which we've defined as they 'key' of the LearningPackage: - assert self.lp_taxonomy.validate_value(self.learning_pkg_2.key) is True - assert self.lp_taxonomy.validate_value(self.learning_pkg_2.key) is True - assert self.lp_taxonomy.validate_value(valid_lp.key) is True + assert self.lp_taxonomy.validate_value(self.learning_pkg_2.package_ref) is True + assert self.lp_taxonomy.validate_value(self.learning_pkg_2.package_ref) is True + assert self.lp_taxonomy.validate_value(valid_lp.package_ref) is True assert self.lp_taxonomy.validate_value("foo") is False # The taxonomy can also validate tags by external_id, which we've defined as the UUID of the LearningPackage: assert self.lp_taxonomy.validate_external_id(self.learning_pkg_2.uuid) is True