diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index 6b9f74d07..fec2b1ec6 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -19,6 +19,22 @@ auth.User: ".. pii_retirement": "consumer_api" contenttypes.ContentType: ".. no_pii:": "This model has no PII" +openedx_content.Asset: + ".. no_pii:": "This model has no PII" +openedx_content.AssetBundle: + ".. no_pii:": "This model has no PII" +openedx_content.AssetBundleType: + ".. no_pii:": "This model has no PII" +openedx_content.AssetBundleVersion: + ".. no_pii:": "This model has no PII" +openedx_content.AssetBundleVersionAsset: + ".. no_pii:": "This model has no PII" +openedx_content.AssetType: + ".. no_pii:": "This model has no PII" +openedx_content.AssetVersion: + ".. no_pii:": "This model has no PII" +openedx_content.AssetVersionMedia: + ".. no_pii:": "This model has no PII" openedx_content.Collection: ".. no_pii:": "This model has no PII" openedx_content.CollectionPublishableEntity: diff --git a/src/openedx_content/api.py b/src/openedx_content/api.py index a0d7d0ab3..f070b86e8 100644 --- a/src/openedx_content/api.py +++ b/src/openedx_content/api.py @@ -17,6 +17,7 @@ # `DraftChangeLogEventData` vs `DraftChangeLogRecord` is clearer if the former is `signals.DraftChangeLogEventData`) from . import signals # The rest of the public API (other than models): +from .applets.assets.api import * from .applets.backup_restore.api import * from .applets.collections.api import * from .applets.components.api import * diff --git a/src/openedx_content/applets/assets/__init__.py b/src/openedx_content/applets/assets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/openedx_content/applets/assets/admin.py b/src/openedx_content/applets/assets/admin.py new file mode 100644 index 000000000..5dac974cc --- /dev/null +++ b/src/openedx_content/applets/assets/admin.py @@ -0,0 +1,155 @@ +""" +Django admin for the assets applet. + +Unlike Container subclasses (which are browsable through the generic Container +admin), Asset and AssetBundle are standalone PublishableEntities, so they each +get their own read-only admin here. +""" +from django.contrib import admin +from django.template.defaultfilters import filesizeformat + +from openedx_django_lib.admin_utils import ReadOnlyModelAdmin, model_detail_link + +from .models import Asset, AssetBundle, AssetBundleType, AssetBundleVersion, AssetType, AssetVersion + + +@admin.register(AssetType) +class AssetTypeAdmin(ReadOnlyModelAdmin): + """Read-only admin for AssetType.""" + + list_display = ("code",) + search_fields = ("code",) + + +@admin.register(AssetBundleType) +class AssetBundleTypeAdmin(ReadOnlyModelAdmin): + """Read-only admin for AssetBundleType.""" + + list_display = ("code",) + search_fields = ("code",) + + +class AssetVersionInline(admin.TabularInline): + """Inline view of AssetVersions from the Asset admin.""" + + model = AssetVersion + fields = ["version_num", "title", "uuid", "created"] + readonly_fields = fields # type: ignore[assignment] + extra = 0 + + def get_queryset(self, request): + return super().get_queryset(request).select_related("publishable_entity_version") + + +@admin.register(Asset) +class AssetAdmin(ReadOnlyModelAdmin): + """Read-only admin for Asset.""" + + list_display = ("asset_code", "asset_type", "uuid", "created") + readonly_fields = ["learning_package", "asset_type", "asset_code", "uuid", "created"] + list_filter = ("asset_type", "learning_package") + search_fields = ["asset_code", "publishable_entity__uuid", "publishable_entity__entity_ref"] + inlines = [AssetVersionInline] + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + "asset_type", + "publishable_entity", + "publishable_entity__learning_package", + ) + + +class AssetVersionMediaInline(admin.TabularInline): + """Inline view of the Media variants attached to an AssetVersion.""" + + model = AssetVersion.media.through + fields = ["variant", "media", "format_size"] + readonly_fields = fields # type: ignore[assignment] + extra = 0 + + def get_queryset(self, request): + return super().get_queryset(request).select_related("media", "media__media_type") + + @admin.display(description="Size") + def format_size(self, avm_obj): + return filesizeformat(avm_obj.media.size) + + +@admin.register(AssetVersion) +class AssetVersionAdmin(ReadOnlyModelAdmin): + """Read-only admin for AssetVersion.""" + + list_display = ["asset", "version_num", "uuid", "created"] + fields = ["asset", "uuid", "title", "version_num", "created"] + readonly_fields = fields # type: ignore[assignment] + inlines = [AssetVersionMediaInline] + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + "asset", + "asset__publishable_entity", + "publishable_entity_version", + ) + + +class AssetBundleVersionInline(admin.TabularInline): + """Inline view of AssetBundleVersions from the AssetBundle admin.""" + + model = AssetBundleVersion + fields = ["version_num", "title", "uuid", "created"] + readonly_fields = fields # type: ignore[assignment] + extra = 0 + + def get_queryset(self, request): + return super().get_queryset(request).select_related("publishable_entity_version") + + +@admin.register(AssetBundle) +class AssetBundleAdmin(ReadOnlyModelAdmin): + """Read-only admin for AssetBundle.""" + + list_display = ("bundle_code", "asset_bundle_type", "uuid", "created") + readonly_fields = ["learning_package", "asset_bundle_type", "bundle_code", "uuid", "created"] + list_filter = ("asset_bundle_type", "learning_package") + search_fields = ["bundle_code", "publishable_entity__uuid", "publishable_entity__entity_ref"] + inlines = [AssetBundleVersionInline] + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + "asset_bundle_type", + "publishable_entity", + "publishable_entity__learning_package", + ) + + +class AssetBundleVersionAssetInline(admin.TabularInline): + """Inline view of the member Assets of an AssetBundleVersion.""" + + model = AssetBundleVersion.assets.through + fields = ["asset_link"] + readonly_fields = fields # type: ignore[assignment] + extra = 0 + + def get_queryset(self, request): + return super().get_queryset(request).select_related("asset", "asset__asset_type") + + @admin.display(description="Asset") + def asset_link(self, abva_obj): + return model_detail_link(abva_obj.asset, str(abva_obj.asset)) + + +@admin.register(AssetBundleVersion) +class AssetBundleVersionAdmin(ReadOnlyModelAdmin): + """Read-only admin for AssetBundleVersion.""" + + list_display = ["asset_bundle", "version_num", "uuid", "created"] + fields = ["asset_bundle", "uuid", "title", "version_num", "created"] + readonly_fields = fields # type: ignore[assignment] + inlines = [AssetBundleVersionAssetInline] + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + "asset_bundle", + "asset_bundle__publishable_entity", + "publishable_entity_version", + ) diff --git a/src/openedx_content/applets/assets/api.py b/src/openedx_content/applets/assets/api.py new file mode 100644 index 000000000..5725fcc5d --- /dev/null +++ b/src/openedx_content/applets/assets/api.py @@ -0,0 +1,703 @@ +""" +Assets API + +These functions are the supported way to create and read Assets and +AssetBundles. As with the other applets, you should never mutate this app's +models directly (there is bookkeeping across multiple models, and related models +you may not know about); read from the models directly only for queries. + +Please look at the models.py file for more information about what is stored here. +""" +from __future__ import annotations + +import mimetypes +from dataclasses import dataclass +from datetime import datetime +from functools import cache +from typing import Iterable + +from django.core.exceptions import ValidationError +from django.db.models import QuerySet +from django.db.transaction import atomic + +from ..media import api as media_api +from ..media.models import Media +from ..publishing import api as publishing_api +from ..publishing.models import LearningPackage +from .models import ( + Asset, + AssetBundle, + AssetBundleType, + AssetBundleVersion, + AssetBundleVersionAsset, + AssetType, + AssetVersion, + AssetVersionMedia, +) + +# The public API that will be re-exported by openedx_content.api is listed in +# the __all__ entries below. Internal helper functions that are private to this +# module start with an underscore. If a function does not start with an +# underscore AND it is not in __all__, that function is considered to be callable +# only by other applets in the openedx_content package. +__all__ = [ + # Asset + "get_or_create_asset_type", + "create_asset", + "create_asset_version", + "create_asset_and_version", + "create_next_asset_version", + "get_asset", + "get_asset_by_code", + "get_assets", + "AssetFile", + "get_asset_version_media", + # AssetBundle + "get_or_create_asset_bundle_type", + "create_asset_bundle", + "create_asset_bundle_version", + "create_asset_bundle_and_version", + "create_next_asset_bundle_version", + "get_asset_bundle", + "get_asset_bundle_by_code", + "get_asset_bundles", + "AssetBundleMember", + "get_assets_in_bundle", +] + + +######################################################################################################################## +# Asset Types + + +def get_or_create_asset_type(code: str) -> AssetType: + """ + Get the AssetType for ``code``, creating it if it does not yet exist. + + Caching Warning: Be careful about putting any caching decorator around this + function (e.g. ``lru_cache``). It's possible that incorrect cache values + could leak out in the event of a rollback -- e.g. new types are introduced in + a large import transaction which later fails. You can safely cache the + results that come back from this function with a local dict in your import + process instead. + """ + asset_type, _created = AssetType.objects.get_or_create(code=code) + return asset_type + + +def get_or_create_asset_bundle_type(code: str) -> AssetBundleType: + """ + Get the AssetBundleType for ``code``, creating it if missing. + + See the caching warning on ``get_or_create_asset_type``. + """ + asset_bundle_type, _created = AssetBundleType.objects.get_or_create(code=code) + return asset_bundle_type + + +######################################################################################################################## +# Assets + + +def create_asset( + learning_package_id: LearningPackage.ID, + /, + asset_type: AssetType, + asset_code: str, + created: datetime, + created_by: int | None, + *, + can_stand_alone: bool = True, +) -> Asset: + """ + Create a new Asset. + + The ``entity_ref`` is conventionally derived as ``"{asset_type.code}:{asset_code}"``, + although callers should not assume that this will always be true. + """ + entity_ref = f"{asset_type.code}:{asset_code}" + with atomic(): + publishable_entity = publishing_api.create_publishable_entity( + learning_package_id, + entity_ref, + created, + created_by, + can_stand_alone=can_stand_alone, + ) + asset = Asset.objects.create( + publishable_entity=publishable_entity, + learning_package_id=learning_package_id, + asset_type=asset_type, + asset_code=asset_code, + ) + return asset + + +def create_asset_version( + asset_id: Asset.ID, + /, + version_num: int, + title: str, + created: datetime, + created_by: int | None, + *, + media: dict[str, Media.ID | Media | bytes] | None = None, +) -> AssetVersion: + """ + Create a new AssetVersion. + + The ``media`` parameter is a dict of *variant* identifiers to Media-like + things (a ``Media.ID``, ``Media`` object, or raw ``bytes``). This is the set + of file variants we want to associate with the new AssetVersion -- for + example ``{"book.epub": ..., "book.pdf": ...}``. + + Media can be specified as ``bytes`` for testing convenience, but you will + almost always want to create a Media object first in actual app code, because + that gives you better control over the MIME type and storage specifics (file + vs. database). + """ + with atomic(): + publishable_entity_version = publishing_api.create_publishable_entity_version( + asset_id, + version_num=version_num, + title=title, + created=created, + created_by=created_by, + ) + asset_version = AssetVersion.objects.create( + publishable_entity_version=publishable_entity_version, + asset_id=asset_id, + ) + if media: + _set_asset_version_media(asset_version, media, created=created) + + return asset_version + + +def create_asset_and_version( # pylint: disable=too-many-positional-arguments + learning_package_id: LearningPackage.ID, + /, + asset_type: AssetType, + asset_code: str, + title: str, + created: datetime, + created_by: int | None = None, + *, + can_stand_alone: bool = True, + media: dict[str, Media.ID | Media | bytes] | None = None, +) -> tuple[Asset, AssetVersion]: + """ + Create an Asset and its first AssetVersion atomically. + """ + with atomic(): + asset = create_asset( + learning_package_id, + asset_type, + asset_code, + created, + created_by, + can_stand_alone=can_stand_alone, + ) + asset_version = create_asset_version( + asset.id, + version_num=1, + title=title, + created=created, + created_by=created_by, + media=media or {}, + ) + + return (asset, asset_version) + + +def create_next_asset_version( + asset_id: Asset.ID, + /, + media_to_replace: dict[str, Media.ID | Media | bytes | None], + created: datetime, + title: str | None = None, + created_by: int | None = None, + *, + force_version_num: int | None = None, + ignore_previous_media: bool = False, +) -> AssetVersion: + """ + Create a new AssetVersion based on the most recent version. + + ``media_to_replace`` maps *variant* identifiers to a ``Media.ID``, ``Media``, + ``bytes`` (a new file), or ``None`` (to delete that variant in the next + version). Unless ``ignore_previous_media`` is set, the previous version's + media is carried over and ``media_to_replace`` is applied as a delta on top. + It is okay to mark variants for deletion that don't exist. + + Use ``force_version_num`` to set a specific version number (e.g. when + restoring from backup or importing legacy data); otherwise the version number + is incremented automatically from the latest version. + + This mirrors ``components.api.create_next_component_version``. + """ + asset = Asset.objects.get(pk=asset_id) + last_version = asset.versioning.latest + if last_version is None: + next_version_num = 1 + title = title or "" + else: + next_version_num = last_version.version_num + 1 + if title is None: + title = last_version.title + + if force_version_num is not None: + next_version_num = force_version_num + + with atomic(): + publishable_entity_version = publishing_api.create_publishable_entity_version( + asset_id, + version_num=next_version_num, + title=title, + created=created, + created_by=created_by, + ) + asset_version = AssetVersion.objects.create( + publishable_entity_version=publishable_entity_version, + asset_id=asset_id, + ) + + if ignore_previous_media or last_version is None: + variants_to_media = { + variant: media + for variant, media in media_to_replace.items() + if media is not None # Ignore deletion entries in this case. + } + else: + # Most of the time, we're adding our media changes as a delta on top + # of the last version's media. + previous_media = { + avm.variant: avm.media_id + for avm in AssetVersionMedia.objects.filter(asset_version=last_version) + } + variants_to_media = { + variant: media + for variant, media in (previous_media | media_to_replace).items() + if media is not None # "media is None" means "delete this" + } + + _set_asset_version_media(asset_version, variants_to_media, created) + + return asset_version + + +def _set_asset_version_media( + version: AssetVersion, + variants_to_media_values: dict[str, Media.ID | Media | bytes], + created: datetime, +) -> None: + """ + Internal helper to set the Media variants for this AssetVersion. + + Only call this when first initializing an AssetVersion. Media can be + specified as ``bytes`` for testing convenience (the MIME type is guessed from + the variant name, so a variant like ``"book.epub"`` works best), but you will + almost always want to create a Media object first in actual app code. + + Mirrors ``components.api._set_component_version_media``. + """ + @cache # avoid repeated lookups, e.g. an asset with several variants of one type + def cached_media_type(media_type_str): + return media_api.get_or_create_media_type(media_type_str) + + def valid_variant(variant): + """No absolute paths, surrounding whitespace, or backslashes (Windows separators).""" + return variant == variant.strip().lstrip("/") and "\\" not in variant + + # Normalize to media_ids for the bulk insert below. + variants_to_media_ids: dict[str, Media.ID] = {} + + av_learning_package_id = version.asset.learning_package_id + + for variant, media_value in variants_to_media_values.items(): + if not valid_variant(variant): + raise ValueError(f"{variant!r} is an invalid media variant ({version!r})") + + match media_value: + case int(): # Media.ID + media_id = media_value + case Media(): + media_id = media_value.id + if media_value.learning_package_id != av_learning_package_id: + raise ValueError( + f"Media LearningPackage does not match Asset: " + f"Tried to create AssetVersion {version!r} " + f"(Learning Package ID {av_learning_package_id!r}) " + f"with Media {media_value!r} " + f"(Learning Package ID {media_value.learning_package_id!r})" + ) + case bytes(): + media_type_str, _encoding = mimetypes.guess_type(variant) + # We use "application/octet-stream" as a generic fallback media + # type, per RFC 2046. + media_type_str = media_type_str or "application/octet-stream" + media_type = cached_media_type(media_type_str) + media = media_api.get_or_create_file_media( + av_learning_package_id, + media_type.id, + data=media_value, + created=created, + ) + media_id = media.id + case _: + raise ValueError(f"Invalid object for media variant: {media_value!r}") + + variants_to_media_ids[variant] = media_id + + AssetVersionMedia.objects.bulk_create( + [ + AssetVersionMedia( + asset_version=version, + variant=variant, + media_id=media_id, + ) + for variant, media_id in variants_to_media_ids.items() + ] + ) + + +def get_asset(asset_id: Asset.ID, /) -> Asset: + """ + Get an Asset by its primary key (same as its PublishableEntity's ID). + """ + return Asset.with_publishing_relations.get(pk=asset_id) + + +def get_asset_by_code( + learning_package_id: LearningPackage.ID, + /, + asset_type_code: str, + asset_code: str, +) -> Asset: + """ + Get an Asset by its unique ``(asset_type, asset_code)`` within a LearningPackage. + """ + return Asset.with_publishing_relations.get( + learning_package_id=learning_package_id, + asset_type__code=asset_type_code, + asset_code=asset_code, + ) + + +def get_assets( + learning_package_id: LearningPackage.ID, + /, + draft: bool | None = None, + published: bool | None = None, + asset_type_code: str | None = None, +) -> QuerySet[Asset]: + """ + Fetch a QuerySet of Assets for a LearningPackage, with optional filters. + + Preloads the relations needed to read each Asset's draft and published + versions. + """ + qset = Asset.with_publishing_relations.filter(learning_package_id=learning_package_id).order_by("pk") + + if draft is not None: + qset = qset.filter(publishable_entity__draft__version__isnull=not draft) + if published is not None: + qset = qset.filter(publishable_entity__published__version__isnull=not published) + if asset_type_code is not None: + qset = qset.filter(asset_type__code=asset_type_code) + + return qset + + +@dataclass(frozen=True) +class AssetFile: + """One file variant of an AssetVersion.""" + + variant: str + media: Media + + +def get_asset_version_media(asset_version: AssetVersion) -> list[AssetFile]: + """ + Return the list of file variants (variant + Media) for an AssetVersion. + """ + return [ + AssetFile(variant=avm.variant, media=avm.media) + for avm in asset_version.assetversionmedia_set.select_related( + "media", "media__media_type" + ).order_by("variant") + ] + + +######################################################################################################################## +# Asset Bundles + + +def create_asset_bundle( + learning_package_id: LearningPackage.ID, + /, + asset_bundle_type: AssetBundleType, + bundle_code: str, + created: datetime, + created_by: int | None, + *, + can_stand_alone: bool = True, +) -> AssetBundle: + """ + Create a new AssetBundle. + + The ``entity_ref`` is conventionally derived as + ``"{asset_bundle_type.code}:{bundle_code}"``. + """ + entity_ref = f"{asset_bundle_type.code}:{bundle_code}" + with atomic(): + publishable_entity = publishing_api.create_publishable_entity( + learning_package_id, + entity_ref, + created, + created_by, + can_stand_alone=can_stand_alone, + ) + asset_bundle = AssetBundle.objects.create( + publishable_entity=publishable_entity, + learning_package_id=learning_package_id, + asset_bundle_type=asset_bundle_type, + bundle_code=bundle_code, + ) + return asset_bundle + + +def create_asset_bundle_version( + asset_bundle_id: AssetBundle.ID, + /, + version_num: int, + *, + title: str, + assets: Iterable[Asset] | None = None, + created: datetime, + created_by: int | None, +) -> AssetBundleVersion: + """ + Create a new AssetBundleVersion with the given (unordered) set of member Assets. + + All member Assets must belong to the same LearningPackage as the bundle. The + members are registered as publishing *dependencies* of this version, so that + the publishing system's "unpublished changes" detection accounts for changes + to the member Assets. + """ + asset_list = list(assets or []) + with atomic(): + bundle = AssetBundle.objects.select_related("publishable_entity").get(pk=asset_bundle_id) + learning_package_id = bundle.publishable_entity.learning_package_id + + # Validate that all members are from the bundle's learning package: + if asset_list and ( + Asset.objects.filter(pk__in=[asset.id for asset in asset_list]) + .exclude(learning_package_id=learning_package_id) + .exists() + ): + raise ValidationError("AssetBundle members must be from the same learning package.") + + publishable_entity_version = publishing_api.create_publishable_entity_version( + asset_bundle_id, + version_num=version_num, + title=title, + created=created, + created_by=created_by, + # Members are unpinned references, so they are dependencies of this version. + dependencies=[asset.id for asset in asset_list], + ) + asset_bundle_version = AssetBundleVersion.objects.create( + publishable_entity_version=publishable_entity_version, + asset_bundle_id=asset_bundle_id, + ) + AssetBundleVersionAsset.objects.bulk_create( + [ + AssetBundleVersionAsset(asset_bundle_version=asset_bundle_version, asset_id=asset.id) + for asset in asset_list + ] + ) + + return asset_bundle_version + + +def create_asset_bundle_and_version( + learning_package_id: LearningPackage.ID, + /, + asset_bundle_type: AssetBundleType, + bundle_code: str, + *, + title: str, + assets: Iterable[Asset] | None = None, + created: datetime, + created_by: int | None = None, + can_stand_alone: bool = True, +) -> tuple[AssetBundle, AssetBundleVersion]: + """ + Create an AssetBundle and its first AssetBundleVersion atomically. + """ + with atomic(): + asset_bundle = create_asset_bundle( + learning_package_id, + asset_bundle_type, + bundle_code, + created, + created_by, + can_stand_alone=can_stand_alone, + ) + asset_bundle_version = create_asset_bundle_version( + asset_bundle.id, + 1, + title=title, + assets=assets or [], + created=created, + created_by=created_by, + ) + return asset_bundle, asset_bundle_version + + +def create_next_asset_bundle_version( + asset_bundle: AssetBundle | AssetBundle.ID, + /, + *, + title: str | None = None, + assets: Iterable[Asset] | None = None, + created: datetime, + created_by: int | None, + force_version_num: int | None = None, +) -> AssetBundleVersion: + """ + Create the next version of an AssetBundle. + + If ``assets`` is ``None``, the previous version's membership is carried over + (use this for metadata-only changes, e.g. a title change). Otherwise the + membership is *replaced* with the given set. Pass ``title=None`` to keep the + current title. + + Use ``force_version_num`` to set a specific version number (e.g. when + restoring from backup or importing legacy data). + """ + with atomic(): + if isinstance(asset_bundle, int): + asset_bundle = AssetBundle.objects.select_related("publishable_entity").get(pk=asset_bundle) + assert isinstance(asset_bundle, AssetBundle) + + last_version = asset_bundle.versioning.latest + if last_version is None: + next_version_num = 1 + else: + next_version_num = last_version.version_num + 1 + if force_version_num is not None: + next_version_num = force_version_num + + if assets is None: + # Metadata-only change: keep the same membership as the last version. + assets = list(last_version.assets.all()) if last_version is not None else [] + + if title is None: + title = last_version.title if last_version is not None else "" + + return create_asset_bundle_version( + asset_bundle.id, + next_version_num, + title=title, + assets=assets, + created=created, + created_by=created_by, + ) + + +def get_asset_bundle(asset_bundle_id: AssetBundle.ID, /) -> AssetBundle: + """ + Get an AssetBundle by its primary key (same as its PublishableEntity's ID). + """ + return AssetBundle.with_publishing_relations.get(pk=asset_bundle_id) + + +def get_asset_bundle_by_code( + learning_package_id: LearningPackage.ID, + /, + asset_bundle_type_code: str, + bundle_code: str, +) -> AssetBundle: + """ + Get an AssetBundle by its unique ``(asset_bundle_type, bundle_code)`` within a LearningPackage. + """ + return AssetBundle.with_publishing_relations.get( + learning_package_id=learning_package_id, + asset_bundle_type__code=asset_bundle_type_code, + bundle_code=bundle_code, + ) + + +def get_asset_bundles( + learning_package_id: LearningPackage.ID, + /, + draft: bool | None = None, + published: bool | None = None, + asset_bundle_type_code: str | None = None, +) -> QuerySet[AssetBundle]: + """ + Fetch a QuerySet of AssetBundles for a LearningPackage, with optional filters. + """ + qset = AssetBundle.with_publishing_relations.filter( + learning_package_id=learning_package_id + ).order_by("pk") + + if draft is not None: + qset = qset.filter(publishable_entity__draft__version__isnull=not draft) + if published is not None: + qset = qset.filter(publishable_entity__published__version__isnull=not published) + if asset_bundle_type_code is not None: + qset = qset.filter(asset_bundle_type__code=asset_bundle_type_code) + + return qset + + +@dataclass(frozen=True) +class AssetBundleMember: + """A single member Asset of an AssetBundle, resolved to a specific AssetVersion.""" + + asset_version: AssetVersion + + @property + def asset(self) -> Asset: + return self.asset_version.asset + + +def get_assets_in_bundle( + asset_bundle: AssetBundle, + *, + published: bool, +) -> list[AssetBundleMember]: + """ + Get the member Assets (resolved to their versions) of the draft or published + version of the given AssetBundle. + + Members are unpinned, so each member Asset is resolved to its current draft + or published AssetVersion, as appropriate. Members whose current version is + ``None`` (e.g. soft-deleted Assets) are skipped. + + Args: + asset_bundle: The AssetBundle, e.g. returned by ``get_asset_bundle()``. + published: ``True`` for the published version of the bundle, ``False`` for + the draft version. + """ + assert isinstance(asset_bundle, AssetBundle) + bundle_version = asset_bundle.versioning.published if published else asset_bundle.versioning.draft + if bundle_version is None: + # This bundle has not been published yet, or has been deleted. + raise AssetBundleVersion.DoesNotExist + assert isinstance(bundle_version, AssetBundleVersion) + + if published: + version_rel = "asset__publishable_entity__published__version__assetversion" + else: + version_rel = "asset__publishable_entity__draft__version__assetversion" + + members: list[AssetBundleMember] = [] + for row in bundle_version.assetbundleversionasset_set.select_related("asset", version_rel): + asset_version = row.asset.versioning.published if published else row.asset.versioning.draft + if asset_version is not None: # Skip soft-deleted members. + members.append(AssetBundleMember(asset_version=asset_version)) + return members diff --git a/src/openedx_content/applets/assets/models.py b/src/openedx_content/applets/assets/models.py new file mode 100644 index 000000000..a388fa0f3 --- /dev/null +++ b/src/openedx_content/applets/assets/models.py @@ -0,0 +1,369 @@ +""" +Models for digital asset management. + +This applet introduces two new ``PublishableEntity`` types: + +* An **Asset** is logically one thing, even though it may exist as several file + *variants*. For example, an ebook Asset might be available as epub, pdf, and + mobi files simultaneously. Those files are modeled as a M:M relation between + ``AssetVersion`` and ``Media`` (via ``AssetVersionMedia``), mirroring the way + ``ComponentVersion`` relates to ``Media``. Because the relation is on the + *version*, changing an Asset's files creates a new ``AssetVersion`` that flows + through the publishing (draft/publish) system. + +* An **AssetBundle** is a group of related Assets that logically go together -- + for instance, a video file together with its VTT subtitles. Membership is a + M:M relation between ``AssetBundleVersion`` and ``Asset`` (via + ``AssetBundleVersionAsset``). It is an *unordered* set: Assets have no + intrinsic ordering within a bundle. Unlike Containers, AssetBundles do **not** + use ``EntityList``/``EntityListRow`` and are not ``Container`` subclasses. + +Both Asset and AssetBundle are classified by a normalized type +(``AssetType`` / ``AssetBundleType``), in the same spirit as ``ComponentType`` +and ``ContainerType``. The type's ``code`` is also used to build the underlying +``PublishableEntity``'s ``entity_ref`` (see this applet's ``api.py``). + +This applet stays deliberately generic: it does not define specialized +subclasses (e.g. ``ImageAsset``, ``VideoAssetBundle``). Those will come from +more granular applets later, via multi-table inheritance from ``Asset`` / +``AssetBundle``. + +Note: elsewhere in this codebase the word "asset" is sometimes used to mean "a +static file served to a browser" (see ``ComponentVersionMedia`` and the +``*_component_asset`` helpers in the ``components`` applet). The ``Asset`` and +``AssetBundle`` models here are higher-level PublishableEntities and are +distinct from that lower-level usage. +""" +from __future__ import annotations + +from typing import ClassVar, NewType, cast + +from django.db import models +from typing_extensions import deprecated + +from openedx_django_lib.fields import case_sensitive_char_field, code_field, code_field_check, ref_field +from openedx_django_lib.managers import WithRelationsManager + +from ..media.models import Media +from ..publishing.models import ( + LearningPackage, + PublishableEntity, + PublishableEntityMixin, + PublishableEntityVersionMixin, +) + +__all__ = [ + "AssetType", + "AssetBundleType", + "Asset", + "AssetVersion", + "AssetVersionMedia", + "AssetBundle", + "AssetBundleVersion", + "AssetBundleVersionAsset", +] + + +class AssetType(models.Model): + """ + Normalized representation of the type of an Asset. + + This is a lightweight classification (e.g. "ebook", "document"), in the same + spirit as ``ComponentType`` and ``ContainerType``. It does *not* introduce + specialized behavior; that will come later from Asset subclasses in more + granular applets. + + Plugins/apps that add their own AssetTypes should prefix the code, e.g. + "myapp_custom_ebook" instead of "custom_ebook", to avoid collisions. + """ + + # We don't need the app default of 8 bytes for this primary key; type tables + # stay small, just like ComponentType and MediaType. + id = models.AutoField(primary_key=True) + + # code uniquely identifies the type of asset, e.g. "ebook", "document". + code = case_sensitive_char_field(max_length=100, blank=False, unique=True) + + class Meta: + constraints = [ + models.CheckConstraint( + # No whitespace, uppercase, or special characters allowed in "code". + condition=models.lookups.Regex(models.F("code"), r"^[a-z0-9\-_\.]+$"), + name="oel_assettype_code_rx", + ), + ] + + def __str__(self) -> str: # pylint: disable=invalid-str-returned + return self.code + + +class AssetBundleType(models.Model): + """ + Normalized representation of the type of an AssetBundle. + + See ``AssetType`` for the rationale. Typical codes might be + "video_with_subtitles" or similar. + """ + + id = models.AutoField(primary_key=True) + + code = case_sensitive_char_field(max_length=100, blank=False, unique=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=models.lookups.Regex(models.F("code"), r"^[a-z0-9\-_\.]+$"), + name="oel_assetbundletype_code_rx", + ), + ] + + def __str__(self) -> str: # pylint: disable=invalid-str-returned + return self.code + + +class Asset(PublishableEntityMixin): + """ + A single digital asset that may exist as multiple file variants. + + An Asset is logically one thing (e.g. "Chapter 1 ebook"), even though it may + be available in several formats. The actual files live on ``AssetVersion`` + via the M:M to ``Media``. + + An Asset is 1:1 with ``PublishableEntity`` and shares its primary key, just + like ``Component``. Make a foreign key to this model when you need a stable + reference for as long as the LearningPackage exists. + """ + + AssetID = NewType("AssetID", PublishableEntity.ID) + type ID = AssetID + + @property + def id(self) -> ID: + return cast(Asset.ID, self.publishable_entity_id) + + @property + @deprecated("Use .id instead") + def pk(self): + """Mark the .pk attribute as deprecated (use .id); see Component.pk.""" + return self.id + + # Default manager preloads the (frequently accessed) asset_type lookup. + objects: ClassVar[WithRelationsManager[Asset]] = WithRelationsManager( # type: ignore[assignment] + "asset_type" + ) + + with_publishing_relations = WithRelationsManager( + "asset_type", + "publishable_entity", + "publishable_entity__draft__version", + "publishable_entity__draft__version__assetversion", + "publishable_entity__published__version", + "publishable_entity__published__version__assetversion", + ) + + # Redundant with the publishable_entity relation, but having the FK directly + # lets us build efficient single-table indexes (see Component.learning_package). + learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) + + # What kind of Asset this is. Used (along with asset_code) to derive the + # publishable_entity.entity_ref. + asset_type = models.ForeignKey(AssetType, on_delete=models.PROTECT) + + # asset_code is an identifier local to the (learning_package, asset_type). + asset_code = code_field(unicode=True) + + class Meta: + constraints = [ + # (asset_type, asset_code) is unique within a LearningPackage. Two + # Assets in the same LearningPackage may share an asset_code if their + # asset_types differ (same convention as Component). + models.UniqueConstraint( + fields=["learning_package", "asset_type", "asset_code"], + name="oel_asset_uniq_lp_at_code", + ), + code_field_check("asset_code", name="oel_asset_code_regex", unicode=True), + ] + indexes = [ + # Search by Asset fields across all LearningPackages (e.g. a + # support-oriented Django Admin tool). + models.Index( + fields=["asset_type", "asset_code"], + name="oel_asset_idx_at_code", + ), + ] + verbose_name = "Asset" + verbose_name_plural = "Assets" + + def __str__(self) -> str: + return f"{self.asset_type.code}:{self.asset_code}" + + +class AssetVersion(PublishableEntityVersionMixin): + """ + A particular version of an Asset. + + This holds the actual files via a M:M relationship with ``Media`` through + ``AssetVersionMedia``. Each row is one file *variant* of this version (e.g. + the epub vs. pdf vs. mobi of an ebook). + """ + + # Technically redundant (reachable via publishable_entity_version), but + # convenient. + asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="versions") + + media: models.ManyToManyField[Media, AssetVersionMedia] = models.ManyToManyField( + Media, + through="AssetVersionMedia", + related_name="asset_versions", + ) + + class Meta: + verbose_name = "Asset Version" + verbose_name_plural = "Asset Versions" + + +class AssetVersionMedia(models.Model): + """ + Associates a piece of ``Media`` (a file) with an ``AssetVersion``. + + An AssetVersion may be associated with multiple pieces of Media -- the + different variants/formats of the Asset. Each association has a ``variant`` + identifier that is unique within the AssetVersion (e.g. "epub", "book.pdf"). + This is analogous to ``ComponentVersionMedia.path``. + + Media is immutable and shareable across multiple AssetVersions. + """ + + asset_version = models.ForeignKey(AssetVersion, on_delete=models.CASCADE) + media = models.ForeignKey(Media, on_delete=models.RESTRICT) + + # variant is a local identifier for the file within an AssetVersion, e.g. + # "epub" or "book.pdf". + variant = ref_field() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["asset_version", "variant"], + name="oel_avmedia_uniq_av_variant", + ), + ] + indexes = [ + models.Index(fields=["media", "asset_version"], name="oel_avmedia_m_av"), + models.Index(fields=["asset_version", "media"], name="oel_avmedia_av_m"), + ] + + +class AssetBundle(PublishableEntityMixin): + """ + A group of related Assets that logically go together. + + For example, a video file together with its VTT subtitle files. Membership + is held by ``AssetBundleVersion`` (via ``AssetBundleVersionAsset``) so that + changing the membership creates a new version that flows through publishing. + + An AssetBundle is 1:1 with ``PublishableEntity`` and shares its primary key, + just like ``Asset``. It is *not* a ``Container`` subclass and does not use + ``EntityList``/``EntityListRow``. + """ + + AssetBundleID = NewType("AssetBundleID", PublishableEntity.ID) + type ID = AssetBundleID + + @property + def id(self) -> ID: + return cast(AssetBundle.ID, self.publishable_entity_id) + + @property + @deprecated("Use .id instead") + def pk(self): + """Mark the .pk attribute as deprecated (use .id); see Component.pk.""" + return self.id + + objects: ClassVar[WithRelationsManager[AssetBundle]] = WithRelationsManager( # type: ignore[assignment] + "asset_bundle_type" + ) + + with_publishing_relations = WithRelationsManager( + "asset_bundle_type", + "publishable_entity", + "publishable_entity__draft__version", + "publishable_entity__draft__version__assetbundleversion", + "publishable_entity__published__version", + "publishable_entity__published__version__assetbundleversion", + ) + + learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) + + asset_bundle_type = models.ForeignKey(AssetBundleType, on_delete=models.PROTECT) + + # bundle_code is an identifier local to the (learning_package, asset_bundle_type). + bundle_code = code_field(unicode=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["learning_package", "asset_bundle_type", "bundle_code"], + name="oel_assetbundle_uniq_lp_abt_code", + ), + code_field_check("bundle_code", name="oel_assetbundle_code_regex", unicode=True), + ] + indexes = [ + models.Index( + fields=["asset_bundle_type", "bundle_code"], + name="oel_assetbundle_idx_abt_code", + ), + ] + verbose_name = "Asset Bundle" + verbose_name_plural = "Asset Bundles" + + def __str__(self) -> str: + return f"{self.asset_bundle_type.code}:{self.bundle_code}" + + +class AssetBundleVersion(PublishableEntityVersionMixin): + """ + A particular version of an AssetBundle. + + The set of member Assets for this version is defined via the M:M to + ``Asset`` through ``AssetBundleVersionAsset``. + """ + + asset_bundle = models.ForeignKey(AssetBundle, on_delete=models.CASCADE, related_name="versions") + + assets: models.ManyToManyField[Asset, AssetBundleVersionAsset] = models.ManyToManyField( + Asset, + through="AssetBundleVersionAsset", + related_name="bundle_versions", + ) + + class Meta: + verbose_name = "Asset Bundle Version" + verbose_name_plural = "Asset Bundle Versions" + + +class AssetBundleVersionAsset(models.Model): + """ + Membership row linking an ``AssetBundleVersion`` to one of its member Assets. + + This is an *unordered* set (no ``order_num``). Members reference the + ``Asset`` itself (not a specific ``AssetVersion``); when read, the Asset + resolves to its current draft or published version, as appropriate. Because + the foreign key points specifically at ``Asset``, only Assets can be members + of a bundle (no runtime type-check is needed). + """ + + asset_bundle_version = models.ForeignKey(AssetBundleVersion, on_delete=models.CASCADE) + asset = models.ForeignKey(Asset, on_delete=models.RESTRICT) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["asset_bundle_version", "asset"], + name="oel_abva_uniq_abv_asset", + ), + ] + indexes = [ + # Reverse lookup: "which bundle versions contain this Asset?" + models.Index(fields=["asset", "asset_bundle_version"], name="oel_abva_asset_abv"), + ] diff --git a/src/openedx_content/apps.py b/src/openedx_content/apps.py index 9f2e74f2d..4daf58236 100644 --- a/src/openedx_content/apps.py +++ b/src/openedx_content/apps.py @@ -26,6 +26,10 @@ def register_publishable_models(self): """ from .api import register_publishable_models from .models import ( + Asset, + AssetBundle, + AssetBundleVersion, + AssetVersion, Component, ComponentVersion, Container, @@ -37,6 +41,8 @@ def register_publishable_models(self): Unit, UnitVersion, ) + register_publishable_models(Asset, AssetVersion) + register_publishable_models(AssetBundle, AssetBundleVersion) register_publishable_models(Component, ComponentVersion) register_publishable_models(Container, ContainerVersion) register_publishable_models(Section, SectionVersion) diff --git a/src/openedx_content/migrations/0015_asset_assetbundletype_assetbundle_assetbundleversion_and_more.py b/src/openedx_content/migrations/0015_asset_assetbundletype_assetbundle_assetbundleversion_and_more.py new file mode 100644 index 000000000..6dd6855f8 --- /dev/null +++ b/src/openedx_content/migrations/0015_asset_assetbundletype_assetbundle_assetbundleversion_and_more.py @@ -0,0 +1,161 @@ +# Generated by Django 5.2.13 on 2026-06-01 02:40 + +import django.core.validators +import django.db.models.deletion +import django.db.models.lookups +import openedx_django_lib.fields +import re +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('openedx_content', '0014_typed_media_id'), + ] + + operations = [ + migrations.CreateModel( + name='Asset', + fields=[ + ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='openedx_content.publishableentity')), + ('asset_code', openedx_django_lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=255, validators=[django.core.validators.RegexValidator(re.compile('^[\\w.-]+\\Z'), 'Enter a valid "code name" consisting of any letters, numbers, underscores, hyphens, or periods.', 'invalid')])), + ('learning_package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='openedx_content.learningpackage')), + ], + options={ + 'verbose_name': 'Asset', + 'verbose_name_plural': 'Assets', + }, + ), + migrations.CreateModel( + name='AssetBundleType', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('code', openedx_django_lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=100, unique=True)), + ], + options={ + 'constraints': [models.CheckConstraint(condition=django.db.models.lookups.Regex(models.F('code'), '^[a-z0-9\\-_\\.]+$'), name='oel_assetbundletype_code_rx')], + }, + ), + migrations.CreateModel( + name='AssetBundle', + fields=[ + ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='openedx_content.publishableentity')), + ('bundle_code', openedx_django_lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=255, validators=[django.core.validators.RegexValidator(re.compile('^[\\w.-]+\\Z'), 'Enter a valid "code name" consisting of any letters, numbers, underscores, hyphens, or periods.', 'invalid')])), + ('learning_package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='openedx_content.learningpackage')), + ('asset_bundle_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='openedx_content.assetbundletype')), + ], + options={ + 'verbose_name': 'Asset Bundle', + 'verbose_name_plural': 'Asset Bundles', + }, + ), + migrations.CreateModel( + name='AssetBundleVersion', + fields=[ + ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='openedx_content.publishableentityversion')), + ('asset_bundle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='openedx_content.assetbundle')), + ], + options={ + 'verbose_name': 'Asset Bundle Version', + 'verbose_name_plural': 'Asset Bundle Versions', + }, + ), + migrations.CreateModel( + name='AssetBundleVersionAsset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('asset', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='openedx_content.asset')), + ('asset_bundle_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='openedx_content.assetbundleversion')), + ], + ), + migrations.AddField( + model_name='assetbundleversion', + name='assets', + field=models.ManyToManyField(related_name='bundle_versions', through='openedx_content.AssetBundleVersionAsset', to='openedx_content.asset'), + ), + migrations.CreateModel( + name='AssetType', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('code', openedx_django_lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=100, unique=True)), + ], + options={ + 'constraints': [models.CheckConstraint(condition=django.db.models.lookups.Regex(models.F('code'), '^[a-z0-9\\-_\\.]+$'), name='oel_assettype_code_rx')], + }, + ), + migrations.AddField( + model_name='asset', + name='asset_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='openedx_content.assettype'), + ), + migrations.CreateModel( + name='AssetVersion', + fields=[ + ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='openedx_content.publishableentityversion')), + ('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='openedx_content.asset')), + ], + options={ + 'verbose_name': 'Asset Version', + 'verbose_name_plural': 'Asset Versions', + }, + ), + migrations.CreateModel( + name='AssetVersionMedia', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('variant', openedx_django_lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=500)), + ('asset_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='openedx_content.assetversion')), + ('media', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='openedx_content.media')), + ], + ), + migrations.AddField( + model_name='assetversion', + name='media', + field=models.ManyToManyField(related_name='asset_versions', through='openedx_content.AssetVersionMedia', to='openedx_content.media'), + ), + migrations.AddIndex( + model_name='assetbundle', + index=models.Index(fields=['asset_bundle_type', 'bundle_code'], name='oel_assetbundle_idx_abt_code'), + ), + migrations.AddConstraint( + model_name='assetbundle', + constraint=models.UniqueConstraint(fields=('learning_package', 'asset_bundle_type', 'bundle_code'), name='oel_assetbundle_uniq_lp_abt_code'), + ), + migrations.AddConstraint( + model_name='assetbundle', + constraint=models.CheckConstraint(condition=django.db.models.lookups.Regex(models.F('bundle_code'), '^[\\w.-]+\\Z'), name='oel_assetbundle_code_regex', violation_error_message='Enter a valid "code name" consisting of any letters, numbers, underscores, hyphens, or periods.'), + ), + migrations.AddIndex( + model_name='assetbundleversionasset', + index=models.Index(fields=['asset', 'asset_bundle_version'], name='oel_abva_asset_abv'), + ), + migrations.AddConstraint( + model_name='assetbundleversionasset', + constraint=models.UniqueConstraint(fields=('asset_bundle_version', 'asset'), name='oel_abva_uniq_abv_asset'), + ), + migrations.AddIndex( + model_name='asset', + index=models.Index(fields=['asset_type', 'asset_code'], name='oel_asset_idx_at_code'), + ), + migrations.AddConstraint( + model_name='asset', + constraint=models.UniqueConstraint(fields=('learning_package', 'asset_type', 'asset_code'), name='oel_asset_uniq_lp_at_code'), + ), + migrations.AddConstraint( + model_name='asset', + constraint=models.CheckConstraint(condition=django.db.models.lookups.Regex(models.F('asset_code'), '^[\\w.-]+\\Z'), name='oel_asset_code_regex', violation_error_message='Enter a valid "code name" consisting of any letters, numbers, underscores, hyphens, or periods.'), + ), + migrations.AddIndex( + model_name='assetversionmedia', + index=models.Index(fields=['media', 'asset_version'], name='oel_avmedia_m_av'), + ), + migrations.AddIndex( + model_name='assetversionmedia', + index=models.Index(fields=['asset_version', 'media'], name='oel_avmedia_av_m'), + ), + migrations.AddConstraint( + model_name='assetversionmedia', + constraint=models.UniqueConstraint(fields=('asset_version', 'variant'), name='oel_avmedia_uniq_av_variant'), + ), + ] diff --git a/src/openedx_content/models.py b/src/openedx_content/models.py index 6a5c696b3..925c90fe9 100644 --- a/src/openedx_content/models.py +++ b/src/openedx_content/models.py @@ -7,6 +7,7 @@ # pylint: disable=wildcard-import +from .applets.assets.models import * from .applets.backup_restore.models import * from .applets.collections.models import * from .applets.components.models import * diff --git a/src/openedx_content/models_api.py b/src/openedx_content/models_api.py index ee62b524b..709fff558 100644 --- a/src/openedx_content/models_api.py +++ b/src/openedx_content/models_api.py @@ -8,6 +8,7 @@ # These wildcard imports are okay because these modules declare __all__. # pylint: disable=wildcard-import +from .applets.assets.models import * from .applets.collections.models import * from .applets.components.models import * from .applets.containers.models import * diff --git a/tests/openedx_content/applets/assets/__init__.py b/tests/openedx_content/applets/assets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/openedx_content/applets/assets/test_api.py b/tests/openedx_content/applets/assets/test_api.py new file mode 100644 index 000000000..68f8d7e3b --- /dev/null +++ b/tests/openedx_content/applets/assets/test_api.py @@ -0,0 +1,242 @@ +""" +Basic tests for the assets API (Assets and AssetBundles). +""" +from datetime import datetime, timezone +from typing import cast + +import pytest +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.test import TestCase + +import openedx_content.api as content_api +from openedx_content.applets.publishing import api as publishing_api +from openedx_content.applets.publishing.models import LearningPackage, PublishableContentModelRegistry +from openedx_content.models_api import Asset, AssetBundle, AssetBundleVersion, AssetType, AssetVersion + + +class AssetsTestCase(TestCase): + """Base class with commonly used test data for the assets applet.""" + + learning_package: LearningPackage + now: datetime + ebook_type: AssetType + document_type: AssetType + + @classmethod + def setUpTestData(cls) -> None: + cls.learning_package = publishing_api.create_learning_package( + package_ref="AssetsTestCase-test-key", + title="Assets Test Case Learning Package", + ) + cls.now = datetime(2024, 1, 1, tzinfo=timezone.utc) + cls.ebook_type = content_api.get_or_create_asset_type("ebook") + cls.document_type = content_api.get_or_create_asset_type("document") + cls.bundle_type = content_api.get_or_create_asset_bundle_type("video_with_subtitles") + + def create_asset( + self, + *, + asset_code: str = "asset_1", + title: str = "Test Asset", + asset_type: AssetType | None = None, + media: dict | None = None, + ) -> tuple[Asset, AssetVersion]: + """Helper to quickly create an Asset and its first version.""" + return content_api.create_asset_and_version( + self.learning_package.id, + asset_type=asset_type or self.ebook_type, + asset_code=asset_code, + title=title, + created=self.now, + created_by=None, + media=media, + ) + + +class AssetApiTestCase(AssetsTestCase): + """Tests for the Asset (leaf) API.""" + + def test_create_asset_and_version(self) -> None: + """A new Asset has a draft v1 and no published version.""" + asset, asset_version = self.create_asset() + assert isinstance(asset, Asset) + assert isinstance(asset_version, AssetVersion) + assert asset_version.version_num == 1 + assert asset_version in asset.versioning.versions.all() + assert asset.versioning.draft == asset_version + assert asset.versioning.published is None + assert asset.versioning.has_unpublished_changes + assert asset.publishable_entity.can_stand_alone + + def test_asset_with_multiple_media_variants(self) -> None: + """An AssetVersion can hold multiple file variants (e.g. pdf + txt).""" + _asset, asset_version = self.create_asset( + media={"book.pdf": b"%PDF-1.4 fake pdf bytes", "notes.txt": b"some notes"}, + ) + files = content_api.get_asset_version_media(asset_version) + assert {f.variant for f in files} == {"book.pdf", "notes.txt"} + by_variant = {f.variant: f for f in files} + assert str(by_variant["book.pdf"].media.media_type) == "application/pdf" + assert str(by_variant["notes.txt"].media.media_type) == "text/plain" + assert by_variant["book.pdf"].media.size == len(b"%PDF-1.4 fake pdf bytes") + + def test_create_next_asset_version(self) -> None: + """Next version carries media forward as a delta; None deletes a variant.""" + asset, _v1 = self.create_asset(media={"book.pdf": b"v1 pdf"}) + v2 = content_api.create_next_asset_version( + asset.id, + media_to_replace={"notes.txt": b"added in v2", "book.pdf": None}, + created=self.now, + ) + assert v2.version_num == 2 + files = content_api.get_asset_version_media(v2) + # book.pdf was deleted, notes.txt was added. + assert {f.variant for f in files} == {"notes.txt"} + + def test_get_asset_and_by_code(self) -> None: + """get_asset / get_asset_by_code round-trip; missing raises DoesNotExist.""" + asset, _v1 = self.create_asset(asset_code="findme") + assert content_api.get_asset(asset.id) == asset + assert content_api.get_asset_by_code(self.learning_package.id, "ebook", "findme") == asset + with pytest.raises(Asset.DoesNotExist): + content_api.get_asset(cast(Asset.ID, -500)) + + def test_get_assets_filters(self) -> None: + """get_assets filters by draft/published state and asset type.""" + published_asset, _ = self.create_asset(asset_code="published") + publishing_api.publish_all_drafts(self.learning_package.id, published_at=self.now) + draft_only_asset, _ = self.create_asset(asset_code="draft_only", asset_type=self.document_type) + + assert list(content_api.get_assets(self.learning_package.id, published=True)) == [published_asset] + assert list(content_api.get_assets(self.learning_package.id, published=False)) == [draft_only_asset] + assert list(content_api.get_assets(self.learning_package.id, asset_type_code="document")) == [ + draft_only_asset + ] + assert set(content_api.get_assets(self.learning_package.id)) == {published_asset, draft_only_asset} + + def test_type_idempotency_and_entity_ref(self) -> None: + """Types are get-or-created idempotently; entity_ref uses the type code.""" + assert content_api.get_or_create_asset_type("ebook") == self.ebook_type + asset, _v1 = self.create_asset(asset_code="my_book") + assert asset.asset_type.code == "ebook" + assert asset.publishable_entity.entity_ref == "ebook:my_book" + + def test_asset_code_unique_per_type(self) -> None: + """asset_code may repeat across types, but not within a (lp, type).""" + self.create_asset(asset_code="shared", asset_type=self.ebook_type) + # Same code, different type -> allowed. + self.create_asset(asset_code="shared", asset_type=self.document_type) + # Same code, same type -> rejected. + with pytest.raises(IntegrityError): + self.create_asset(asset_code="shared", asset_type=self.ebook_type) + + def test_registered_publishable_models(self) -> None: + """Asset/AssetVersion are registered as a publishable model pair.""" + assert PublishableContentModelRegistry.get_versioned_model_cls(Asset) is AssetVersion + + +class AssetBundleApiTestCase(AssetsTestCase): + """Tests for the AssetBundle API and membership.""" + + def setUp(self) -> None: + super().setUp() + self.asset_1, _ = self.create_asset(asset_code="asset_1", title="Asset 1") + self.asset_2, _ = self.create_asset(asset_code="asset_2", title="Asset 2") + + def create_bundle(self, assets=None, *, bundle_code="bundle_1", title="Bundle"): + """Helper to create an AssetBundle and its first version.""" + return content_api.create_asset_bundle_and_version( + self.learning_package.id, + asset_bundle_type=self.bundle_type, + bundle_code=bundle_code, + title=title, + assets=assets, + created=self.now, + created_by=None, + ) + + def test_create_empty_bundle_and_version(self) -> None: + """An empty bundle has a draft v1 and no published version.""" + bundle, bundle_version = self.create_bundle() + assert isinstance(bundle, AssetBundle) + assert isinstance(bundle_version, AssetBundleVersion) + assert bundle_version.version_num == 1 + assert bundle.versioning.draft == bundle_version + assert bundle.versioning.published is None + assert not content_api.get_assets_in_bundle(bundle, published=False) + assert bundle.publishable_entity.entity_ref == "video_with_subtitles:bundle_1" + + def test_bundle_with_members_draft(self) -> None: + """Draft membership resolves each Asset to its current draft version.""" + bundle, _bv = self.create_bundle(assets=[self.asset_1, self.asset_2]) + members = content_api.get_assets_in_bundle(bundle, published=False) + assert {m.asset.id for m in members} == {self.asset_1.id, self.asset_2.id} + # Members are resolved (unpinned) to each Asset's draft AssetVersion. + assert {m.asset_version for m in members} == { + self.asset_1.versioning.draft, + self.asset_2.versioning.draft, + } + # Nothing is published yet. + with pytest.raises(AssetBundleVersion.DoesNotExist): + content_api.get_assets_in_bundle(bundle, published=True) + + def test_publish_and_read_published_members(self) -> None: + """After publishing, the published membership is readable.""" + bundle, _bv = self.create_bundle(assets=[self.asset_1, self.asset_2]) + publishing_api.publish_all_drafts(self.learning_package.id, published_at=self.now) + + bundle = content_api.get_asset_bundle(bundle.id) # re-fetch to clear versioning cache + members = content_api.get_assets_in_bundle(bundle, published=True) + assert {m.asset.id for m in members} == {self.asset_1.id, self.asset_2.id} + + def test_create_next_bundle_version(self) -> None: + """Replacing membership bumps the version; assets=None keeps membership.""" + bundle, _bv = self.create_bundle(assets=[self.asset_1]) + + v2 = content_api.create_next_asset_bundle_version( + bundle.id, + assets=[self.asset_1, self.asset_2], + created=self.now, + created_by=None, + ) + assert v2.version_num == 2 + + # Metadata-only change (assets=None) keeps the same membership. + v3 = content_api.create_next_asset_bundle_version( + bundle.id, + title="Renamed Bundle", + created=self.now, + created_by=None, + ) + assert v3.version_num == 3 + assert v3.title == "Renamed Bundle" + + bundle = content_api.get_asset_bundle(bundle.id) + members = content_api.get_assets_in_bundle(bundle, published=False) + assert {m.asset.id for m in members} == {self.asset_1.id, self.asset_2.id} + + def test_duplicate_member_rejected(self) -> None: + """The same Asset cannot appear twice in a bundle version.""" + with pytest.raises(IntegrityError): + self.create_bundle(assets=[self.asset_1, self.asset_1]) + + def test_member_must_be_same_learning_package(self) -> None: + """An Asset from another learning package cannot be a member.""" + other_lp = publishing_api.create_learning_package( + package_ref="AssetsTestCase-other-lp", + title="Other Learning Package", + ) + other_asset = content_api.create_asset( + other_lp.id, + asset_type=self.ebook_type, + asset_code="foreign", + created=self.now, + created_by=None, + ) + with pytest.raises(ValidationError): + self.create_bundle(assets=[other_asset], bundle_code="bad_bundle") + + def test_registered_publishable_models(self) -> None: + """AssetBundle/AssetBundleVersion are registered as a publishable model pair.""" + assert PublishableContentModelRegistry.get_versioned_model_cls(AssetBundle) is AssetBundleVersion