From 31ad7cf8e6faab22c0bd7b06aff98aee6ce5e913 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 18 Feb 2026 21:24:53 -0500 Subject: [PATCH 01/12] refactor: move contents package to media --- src/openedx_content/admin.py | 2 +- src/openedx_content/api.py | 2 +- .../applets/backup_restore/zipper.py | 6 ++--- src/openedx_content/applets/components/api.py | 10 +++---- .../applets/components/models.py | 2 +- .../applets/{contents => media}/__init__.py | 0 .../applets/{contents => media}/admin.py | 2 +- .../applets/{contents => media}/api.py | 2 +- .../applets/{contents => media}/models.py | 0 src/openedx_content/models.py | 2 +- src/openedx_content/models_api.py | 2 +- .../applets/components/test_api.py | 18 ++++++------- .../applets/components/test_assets.py | 26 +++++++++---------- .../applets/contents/test_file_storage.py | 8 +++--- .../applets/contents/test_media_types.py | 8 +++--- 15 files changed, 45 insertions(+), 45 deletions(-) rename src/openedx_content/applets/{contents => media}/__init__.py (100%) rename src/openedx_content/applets/{contents => media}/admin.py (98%) rename src/openedx_content/applets/{contents => media}/api.py (99%) rename src/openedx_content/applets/{contents => media}/models.py (100%) diff --git a/src/openedx_content/admin.py b/src/openedx_content/admin.py index f603a5d54..c6ec40633 100644 --- a/src/openedx_content/admin.py +++ b/src/openedx_content/admin.py @@ -6,7 +6,7 @@ from .applets.backup_restore.admin import * from .applets.collections.admin import * from .applets.components.admin import * -from .applets.contents.admin import * +from .applets.media.admin import * from .applets.publishing.admin import * from .applets.sections.admin import * from .applets.subsections.admin import * diff --git a/src/openedx_content/api.py b/src/openedx_content/api.py index 67f690ae0..9aaa9b7b9 100644 --- a/src/openedx_content/api.py +++ b/src/openedx_content/api.py @@ -13,7 +13,7 @@ from .applets.backup_restore.api import * from .applets.collections.api import * from .applets.components.api import * -from .applets.contents.api import * +from .applets.media.api import * from .applets.publishing.api import * from .applets.sections.api import * from .applets.subsections.api import * diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 5c45942a7..03e79b5b8 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -31,7 +31,7 @@ from ..collections import api as collections_api from ..components import api as components_api -from ..contents import api as contents_api +from ..media import api as media_api from ..publishing import api as publishing_api from ..sections import api as sections_api from ..subsections import api as subsections_api @@ -985,9 +985,9 @@ def _resolve_static_files( # storing the value as a content instance if not self.learning_package_id: raise ValueError("learning_package_id must be set before resolving static files.") - text_content = contents_api.get_or_create_text_content( + text_content = media.api.get_or_create_text_content( self.learning_package_id, - contents_api.get_or_create_media_type(f"application/vnd.openedx.xblock.v1.{block_type}+xml").id, + media.api.get_or_create_media_type(f"application/vnd.openedx.xblock.v1.{block_type}+xml").id, text=content_bytes.decode("utf-8"), created=self.utc_now, ) diff --git a/src/openedx_content/applets/components/api.py b/src/openedx_content/applets/components/api.py index ae4d6c2df..882ecf7fc 100644 --- a/src/openedx_content/applets/components/api.py +++ b/src/openedx_content/applets/components/api.py @@ -23,7 +23,7 @@ from django.db.transaction import atomic from django.http.response import HttpResponse, HttpResponseNotFound -from ..contents import api as contents_api +from ..media import api as media_api from ..publishing import api as publishing_api from .models import Component, ComponentType, ComponentVersion, ComponentVersionContent @@ -255,8 +255,8 @@ def create_next_component_version( # We use "application/octet-stream" as a generic fallback media type, per # RFC 2046: https://datatracker.ietf.org/doc/html/rfc2046 media_type_str = media_type_str or "application/octet-stream" - media_type = contents_api.get_or_create_media_type(media_type_str) - content = contents_api.get_or_create_file_content( + media_type = media.api.get_or_create_media_type(media_type_str) + content = media.api.get_or_create_file_content( component.learning_package.id, media_type.id, data=file_content, @@ -647,10 +647,10 @@ def _error_header(error: AssetError) -> dict[str, str]: # At this point, we know that there is valid Content that we want to send. # This adds Content-level headers, like the hash/etag and content type. - info_headers.update(contents_api.get_content_info_headers(content)) + info_headers.update(media.api.get_content_info_headers(content)) # Recompute redirect headers (reminder: this should never be cached). - redirect_headers = contents_api.get_redirect_headers(content.path, public) + redirect_headers = media.api.get_redirect_headers(content.path, public) logger.info( "Asset redirect (uncached metadata): " f"{component_version_uuid}/{asset_path} -> {redirect_headers}" diff --git a/src/openedx_content/applets/components/models.py b/src/openedx_content/applets/components/models.py index bf86a4e48..669824093 100644 --- a/src/openedx_content/applets/components/models.py +++ b/src/openedx_content/applets/components/models.py @@ -24,7 +24,7 @@ from openedx_django_lib.fields import case_sensitive_char_field, key_field from openedx_django_lib.managers import WithRelationsManager -from ..contents.models import Content +from ..media.models import Content from ..publishing.models import LearningPackage, PublishableEntityMixin, PublishableEntityVersionMixin __all__ = [ diff --git a/src/openedx_content/applets/contents/__init__.py b/src/openedx_content/applets/media/__init__.py similarity index 100% rename from src/openedx_content/applets/contents/__init__.py rename to src/openedx_content/applets/media/__init__.py diff --git a/src/openedx_content/applets/contents/admin.py b/src/openedx_content/applets/media/admin.py similarity index 98% rename from src/openedx_content/applets/contents/admin.py rename to src/openedx_content/applets/media/admin.py index b845073d5..50bf9fc57 100644 --- a/src/openedx_content/applets/contents/admin.py +++ b/src/openedx_content/applets/media/admin.py @@ -1,5 +1,5 @@ """ -Django admin for contents models +Django admin for media.models """ import base64 diff --git a/src/openedx_content/applets/contents/api.py b/src/openedx_content/applets/media/api.py similarity index 99% rename from src/openedx_content/applets/contents/api.py rename to src/openedx_content/applets/media/api.py index b7495e7c0..31f523c6d 100644 --- a/src/openedx_content/applets/contents/api.py +++ b/src/openedx_content/applets/media/api.py @@ -1,5 +1,5 @@ """ -Low Level Contents API (warning: UNSTABLE, in progress API) +Low Level media.api (warning: UNSTABLE, in progress API) Please look at the models.py file for more information about the kinds of data are stored in this app. diff --git a/src/openedx_content/applets/contents/models.py b/src/openedx_content/applets/media/models.py similarity index 100% rename from src/openedx_content/applets/contents/models.py rename to src/openedx_content/applets/media/models.py diff --git a/src/openedx_content/models.py b/src/openedx_content/models.py index 56f672a67..91696b5f3 100644 --- a/src/openedx_content/models.py +++ b/src/openedx_content/models.py @@ -10,7 +10,7 @@ from .applets.backup_restore.models import * from .applets.collections.models import * from .applets.components.models import * -from .applets.contents.models import * +from .applets.media.models import * from .applets.publishing.models import * from .applets.sections.models import * from .applets.subsections.models import * diff --git a/src/openedx_content/models_api.py b/src/openedx_content/models_api.py index 195554a32..1e035b43f 100644 --- a/src/openedx_content/models_api.py +++ b/src/openedx_content/models_api.py @@ -9,7 +9,7 @@ # pylint: disable=wildcard-import from .applets.collections.models import * from .applets.components.models import * -from .applets.contents.models import * +from .applets.media.models import * from .applets.publishing.models import * from .applets.sections.models import * from .applets.subsections.models import * diff --git a/tests/openedx_content/applets/components/test_api.py b/tests/openedx_content/applets/components/test_api.py index 13bc5c1b2..6c664ace6 100644 --- a/tests/openedx_content/applets/components/test_api.py +++ b/tests/openedx_content/applets/components/test_api.py @@ -12,8 +12,8 @@ from openedx_content.applets.collections.models import Collection from openedx_content.applets.components import api as components_api from openedx_content.applets.components.models import Component, ComponentType -from openedx_content.applets.contents import api as contents_api -from openedx_content.applets.contents.models import MediaType +from openedx_content.applets.media import api as media_api +from openedx_content.applets.media.models import MediaType from openedx_content.applets.publishing import api as publishing_api from openedx_content.applets.publishing.models import LearningPackage @@ -390,7 +390,7 @@ def setUpTestData(cls) -> None: created=cls.now, created_by=None, ) - cls.text_media_type = contents_api.get_or_create_media_type("text/plain") + cls.text_media_type = media.api.get_or_create_media_type("text/plain") def test_add(self): new_version = components_api.create_component_version( @@ -400,7 +400,7 @@ def test_add(self): created=self.now, created_by=None, ) - new_content = contents_api.get_or_create_text_content( + new_content = media.api.get_or_create_text_content( self.learning_package.pk, self.text_media_type.id, text="This is some data", @@ -460,19 +460,19 @@ def test_bytes_content(self): assert content_raw_txt.read_file().read() == bytes_content def test_multiple_versions(self): - hello_content = contents_api.get_or_create_text_content( + hello_content = media.api.get_or_create_text_content( self.learning_package.id, self.text_media_type.id, text="Hello World!", created=self.now, ) - goodbye_content = contents_api.get_or_create_text_content( + goodbye_content = media.api.get_or_create_text_content( self.learning_package.id, self.text_media_type.id, text="Goodbye World!", created=self.now, ) - blank_content = contents_api.get_or_create_text_content( + blank_content = media.api.get_or_create_text_content( self.learning_package.id, self.text_media_type.id, text="", @@ -570,10 +570,10 @@ def test_create_multiple_next_versions_and_diff_content(self): Test creating multiple next versions with different content. This includes a case where we want to ignore previous content. """ - python_source_media_type = contents_api.get_or_create_media_type( + python_source_media_type = media.api.get_or_create_media_type( "text/x-python", ) - python_source_asset = contents_api.get_or_create_file_content( + python_source_asset = media.api.get_or_create_file_content( self.learning_package.id, python_source_media_type.id, data=b"print('hello world!')", diff --git a/tests/openedx_content/applets/components/test_assets.py b/tests/openedx_content/applets/components/test_assets.py index 8c24d35c5..58089e11a 100644 --- a/tests/openedx_content/applets/components/test_assets.py +++ b/tests/openedx_content/applets/components/test_assets.py @@ -9,7 +9,7 @@ from openedx_content.applets.components import api as components_api from openedx_content.applets.components.api import AssetError -from openedx_content.applets.contents import api as contents_api +from openedx_content.applets.media import api as media_api from openedx_content.applets.publishing import api as publishing_api from openedx_content.applets.publishing.models import LearningPackage @@ -18,17 +18,17 @@ class AssetTestCase(TestCase): """ Test serving static assets (Content files, via Component lookup). """ - python_source_media_type: contents_api.MediaType - problem_block_media_type: contents_api.MediaType - html_media_type: contents_api.MediaType + python_source_media_type: media.api.MediaType + problem_block_media_type: media.api.MediaType + html_media_type: media.api.MediaType problem_type: components_api.ComponentType component: components_api.Component component_version: components_api.ComponentVersion - problem_content: contents_api.Content - python_source_asset: contents_api.Content - html_asset_content: contents_api.Content + problem_content: media.api.Content + python_source_asset: media.api.Content + html_asset_content: media.api.Content learning_package: LearningPackage now: datetime @@ -44,13 +44,13 @@ def setUpTestData(cls) -> None: cls.problem_type = components_api.get_or_create_component_type( "xblock.v1", "problem" ) - cls.python_source_media_type = contents_api.get_or_create_media_type( + cls.python_source_media_type = media.api.get_or_create_media_type( "text/x-python", ) - cls.problem_block_media_type = contents_api.get_or_create_media_type( + cls.problem_block_media_type = media.api.get_or_create_media_type( "application/vnd.openedx.xblock.v1.problem+xml", ) - cls.html_media_type = contents_api.get_or_create_media_type("text/html") + 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", @@ -66,7 +66,7 @@ def setUpTestData(cls) -> None: ) # ProblemBlock content that is stored as text Content, not a file. - cls.problem_content = contents_api.get_or_create_text_content( + cls.problem_content = media.api.get_or_create_text_content( cls.learning_package.id, cls.problem_block_media_type.id, text="(pretend problem OLX is here)", @@ -80,7 +80,7 @@ def setUpTestData(cls) -> None: # Python source file, stored as a file. This is hypothetical, as we # don't actually support bundling grader files like this today. - cls.python_source_asset = contents_api.get_or_create_file_content( + cls.python_source_asset = media.api.get_or_create_file_content( cls.learning_package.id, cls.python_source_media_type.id, data=b"print('hello world!')", @@ -93,7 +93,7 @@ def setUpTestData(cls) -> None: ) # An HTML file that is student downloadable - cls.html_asset_content = contents_api.get_or_create_file_content( + cls.html_asset_content = media.api.get_or_create_file_content( cls.learning_package.id, cls.html_media_type.id, data=b"hello world!", diff --git a/tests/openedx_content/applets/contents/test_file_storage.py b/tests/openedx_content/applets/contents/test_file_storage.py index 90d017aa4..701a3b015 100644 --- a/tests/openedx_content/applets/contents/test_file_storage.py +++ b/tests/openedx_content/applets/contents/test_file_storage.py @@ -7,8 +7,8 @@ from django.core.exceptions import ImproperlyConfigured from django.test import TestCase, override_settings -from openedx_content.applets.contents import api as contents_api -from openedx_content.applets.contents.models import get_storage +from openedx_content.applets.media import api as media_api +from openedx_content.applets.media.models import get_storage from openedx_content.applets.publishing import api as publishing_api @@ -32,8 +32,8 @@ def setUp(self) -> None: key="ContentFileStorageTestCase-test-key", title="Content File Storage Test Case Learning Package", ) - self.html_media_type = contents_api.get_or_create_media_type("text/html") - self.html_content = contents_api.get_or_create_file_content( + self.html_media_type = media.api.get_or_create_media_type("text/html") + self.html_content = media.api.get_or_create_file_content( learning_package.id, self.html_media_type.id, data=b"hello world!", diff --git a/tests/openedx_content/applets/contents/test_media_types.py b/tests/openedx_content/applets/contents/test_media_types.py index a08b6b449..fecf6a023 100644 --- a/tests/openedx_content/applets/contents/test_media_types.py +++ b/tests/openedx_content/applets/contents/test_media_types.py @@ -3,7 +3,7 @@ """ from django.test import TestCase -from openedx_content.applets.contents import api as contents_api +from openedx_content.applets.media import api as media_api class MediaTypeTest(TestCase): @@ -14,12 +14,12 @@ def test_get_or_create_dedupe(self): Make sure we're not creating redundant rows for the same media type. """ # The first time, a row is created for "text/html" - text_media_type_1 = contents_api.get_or_create_media_type("text/plain") + text_media_type_1 = media.api.get_or_create_media_type("text/plain") # This should return the previously created row. - text_media_type_2 = contents_api.get_or_create_media_type("text/plain") + text_media_type_2 = media.api.get_or_create_media_type("text/plain") assert text_media_type_1 == text_media_type_2 # This is a different type though... - svg_media_type = contents_api.get_or_create_media_type("image/svg+xml") + svg_media_type = media.api.get_or_create_media_type("image/svg+xml") assert text_media_type_1 != svg_media_type From 401d1ed31d3f1e1ffe4f47b56beada737f8e6f2c Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 18 Feb 2026 21:30:12 -0500 Subject: [PATCH 02/12] temp: fix broken tests --- .../applets/backup_restore/zipper.py | 4 ++-- src/openedx_content/applets/components/api.py | 8 +++---- .../applets/components/test_api.py | 14 +++++------ .../applets/components/test_assets.py | 24 +++++++++---------- .../applets/{contents => media}/__init__.py | 0 .../{contents => media}/test_file_storage.py | 4 ++-- .../{contents => media}/test_media_types.py | 6 ++--- 7 files changed, 30 insertions(+), 30 deletions(-) rename tests/openedx_content/applets/{contents => media}/__init__.py (100%) rename tests/openedx_content/applets/{contents => media}/test_file_storage.py (95%) rename tests/openedx_content/applets/{contents => media}/test_media_types.py (78%) diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 03e79b5b8..801a8d5ca 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -985,9 +985,9 @@ def _resolve_static_files( # storing the value as a content instance if not self.learning_package_id: raise ValueError("learning_package_id must be set before resolving static files.") - text_content = media.api.get_or_create_text_content( + text_content = media_api.get_or_create_text_content( self.learning_package_id, - media.api.get_or_create_media_type(f"application/vnd.openedx.xblock.v1.{block_type}+xml").id, + media_api.get_or_create_media_type(f"application/vnd.openedx.xblock.v1.{block_type}+xml").id, text=content_bytes.decode("utf-8"), created=self.utc_now, ) diff --git a/src/openedx_content/applets/components/api.py b/src/openedx_content/applets/components/api.py index 882ecf7fc..098c78689 100644 --- a/src/openedx_content/applets/components/api.py +++ b/src/openedx_content/applets/components/api.py @@ -255,8 +255,8 @@ def create_next_component_version( # We use "application/octet-stream" as a generic fallback media type, per # RFC 2046: https://datatracker.ietf.org/doc/html/rfc2046 media_type_str = media_type_str or "application/octet-stream" - media_type = media.api.get_or_create_media_type(media_type_str) - content = media.api.get_or_create_file_content( + media_type = media_api.get_or_create_media_type(media_type_str) + content = media_api.get_or_create_file_content( component.learning_package.id, media_type.id, data=file_content, @@ -647,10 +647,10 @@ def _error_header(error: AssetError) -> dict[str, str]: # At this point, we know that there is valid Content that we want to send. # This adds Content-level headers, like the hash/etag and content type. - info_headers.update(media.api.get_content_info_headers(content)) + info_headers.update(media_api.get_content_info_headers(content)) # Recompute redirect headers (reminder: this should never be cached). - redirect_headers = media.api.get_redirect_headers(content.path, public) + redirect_headers = media_api.get_redirect_headers(content.path, public) logger.info( "Asset redirect (uncached metadata): " f"{component_version_uuid}/{asset_path} -> {redirect_headers}" diff --git a/tests/openedx_content/applets/components/test_api.py b/tests/openedx_content/applets/components/test_api.py index 6c664ace6..5b2f78e41 100644 --- a/tests/openedx_content/applets/components/test_api.py +++ b/tests/openedx_content/applets/components/test_api.py @@ -390,7 +390,7 @@ def setUpTestData(cls) -> None: created=cls.now, created_by=None, ) - cls.text_media_type = media.api.get_or_create_media_type("text/plain") + cls.text_media_type = media_api.get_or_create_media_type("text/plain") def test_add(self): new_version = components_api.create_component_version( @@ -400,7 +400,7 @@ def test_add(self): created=self.now, created_by=None, ) - new_content = media.api.get_or_create_text_content( + new_content = media_api.get_or_create_text_content( self.learning_package.pk, self.text_media_type.id, text="This is some data", @@ -460,19 +460,19 @@ def test_bytes_content(self): assert content_raw_txt.read_file().read() == bytes_content def test_multiple_versions(self): - hello_content = media.api.get_or_create_text_content( + hello_content = media_api.get_or_create_text_content( self.learning_package.id, self.text_media_type.id, text="Hello World!", created=self.now, ) - goodbye_content = media.api.get_or_create_text_content( + goodbye_content = media_api.get_or_create_text_content( self.learning_package.id, self.text_media_type.id, text="Goodbye World!", created=self.now, ) - blank_content = media.api.get_or_create_text_content( + blank_content = media_api.get_or_create_text_content( self.learning_package.id, self.text_media_type.id, text="", @@ -570,10 +570,10 @@ def test_create_multiple_next_versions_and_diff_content(self): Test creating multiple next versions with different content. This includes a case where we want to ignore previous content. """ - python_source_media_type = media.api.get_or_create_media_type( + python_source_media_type = media_api.get_or_create_media_type( "text/x-python", ) - python_source_asset = media.api.get_or_create_file_content( + python_source_asset = media_api.get_or_create_file_content( self.learning_package.id, python_source_media_type.id, data=b"print('hello world!')", diff --git a/tests/openedx_content/applets/components/test_assets.py b/tests/openedx_content/applets/components/test_assets.py index 58089e11a..78d57fc34 100644 --- a/tests/openedx_content/applets/components/test_assets.py +++ b/tests/openedx_content/applets/components/test_assets.py @@ -18,17 +18,17 @@ class AssetTestCase(TestCase): """ Test serving static assets (Content files, via Component lookup). """ - python_source_media_type: media.api.MediaType - problem_block_media_type: media.api.MediaType - html_media_type: media.api.MediaType + python_source_media_type: media_api.MediaType + problem_block_media_type: media_api.MediaType + html_media_type: media_api.MediaType problem_type: components_api.ComponentType component: components_api.Component component_version: components_api.ComponentVersion - problem_content: media.api.Content - python_source_asset: media.api.Content - html_asset_content: media.api.Content + problem_content: media_api.Content + python_source_asset: media_api.Content + html_asset_content: media_api.Content learning_package: LearningPackage now: datetime @@ -44,13 +44,13 @@ def setUpTestData(cls) -> None: cls.problem_type = components_api.get_or_create_component_type( "xblock.v1", "problem" ) - cls.python_source_media_type = media.api.get_or_create_media_type( + cls.python_source_media_type = media_api.get_or_create_media_type( "text/x-python", ) - cls.problem_block_media_type = media.api.get_or_create_media_type( + cls.problem_block_media_type = media_api.get_or_create_media_type( "application/vnd.openedx.xblock.v1.problem+xml", ) - cls.html_media_type = media.api.get_or_create_media_type("text/html") + 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", @@ -66,7 +66,7 @@ def setUpTestData(cls) -> None: ) # ProblemBlock content that is stored as text Content, not a file. - cls.problem_content = media.api.get_or_create_text_content( + cls.problem_content = media_api.get_or_create_text_content( cls.learning_package.id, cls.problem_block_media_type.id, text="(pretend problem OLX is here)", @@ -80,7 +80,7 @@ def setUpTestData(cls) -> None: # Python source file, stored as a file. This is hypothetical, as we # don't actually support bundling grader files like this today. - cls.python_source_asset = media.api.get_or_create_file_content( + cls.python_source_asset = media_api.get_or_create_file_content( cls.learning_package.id, cls.python_source_media_type.id, data=b"print('hello world!')", @@ -93,7 +93,7 @@ def setUpTestData(cls) -> None: ) # An HTML file that is student downloadable - cls.html_asset_content = media.api.get_or_create_file_content( + cls.html_asset_content = media_api.get_or_create_file_content( cls.learning_package.id, cls.html_media_type.id, data=b"hello world!", diff --git a/tests/openedx_content/applets/contents/__init__.py b/tests/openedx_content/applets/media/__init__.py similarity index 100% rename from tests/openedx_content/applets/contents/__init__.py rename to tests/openedx_content/applets/media/__init__.py diff --git a/tests/openedx_content/applets/contents/test_file_storage.py b/tests/openedx_content/applets/media/test_file_storage.py similarity index 95% rename from tests/openedx_content/applets/contents/test_file_storage.py rename to tests/openedx_content/applets/media/test_file_storage.py index 701a3b015..3df99d134 100644 --- a/tests/openedx_content/applets/contents/test_file_storage.py +++ b/tests/openedx_content/applets/media/test_file_storage.py @@ -32,8 +32,8 @@ def setUp(self) -> None: key="ContentFileStorageTestCase-test-key", title="Content File Storage Test Case Learning Package", ) - self.html_media_type = media.api.get_or_create_media_type("text/html") - self.html_content = media.api.get_or_create_file_content( + self.html_media_type = media_api.get_or_create_media_type("text/html") + self.html_content = media_api.get_or_create_file_content( learning_package.id, self.html_media_type.id, data=b"hello world!", diff --git a/tests/openedx_content/applets/contents/test_media_types.py b/tests/openedx_content/applets/media/test_media_types.py similarity index 78% rename from tests/openedx_content/applets/contents/test_media_types.py rename to tests/openedx_content/applets/media/test_media_types.py index fecf6a023..145a2606d 100644 --- a/tests/openedx_content/applets/contents/test_media_types.py +++ b/tests/openedx_content/applets/media/test_media_types.py @@ -14,12 +14,12 @@ def test_get_or_create_dedupe(self): Make sure we're not creating redundant rows for the same media type. """ # The first time, a row is created for "text/html" - text_media_type_1 = media.api.get_or_create_media_type("text/plain") + text_media_type_1 = media_api.get_or_create_media_type("text/plain") # This should return the previously created row. - text_media_type_2 = media.api.get_or_create_media_type("text/plain") + text_media_type_2 = media_api.get_or_create_media_type("text/plain") assert text_media_type_1 == text_media_type_2 # This is a different type though... - svg_media_type = media.api.get_or_create_media_type("image/svg+xml") + svg_media_type = media_api.get_or_create_media_type("image/svg+xml") assert text_media_type_1 != svg_media_type From 791180cf89715528644d737a0589692208db75f5 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 18 Feb 2026 22:19:27 -0500 Subject: [PATCH 03/12] temp: model renames (but no migrations yet) --- .../applets/backup_restore/zipper.py | 12 +- .../applets/components/admin.py | 4 +- src/openedx_content/applets/components/api.py | 18 +-- .../applets/components/models.py | 12 +- src/openedx_content/applets/media/admin.py | 12 +- src/openedx_content/applets/media/api.py | 24 ++-- src/openedx_content/applets/media/models.py | 106 +++++++++--------- .../applets/backup_restore/test_backup.py | 4 +- .../applets/components/test_assets.py | 6 +- 9 files changed, 99 insertions(+), 99 deletions(-) diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 801a8d5ca..568131f2c 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -22,8 +22,8 @@ Collection, ComponentType, ComponentVersion, - ComponentVersionContent, - Content, + ComponentVersionMedia, + Media, LearningPackage, PublishableEntity, PublishableEntityVersion, @@ -192,12 +192,12 @@ def get_publishable_entities(self) -> QuerySet[PublishableEntity]: # which is too large for this type of prefetch. Prefetch( "draft__version__componentversion__componentversioncontent_set", - queryset=ComponentVersionContent.objects.select_related("content"), + queryset=ComponentVersionMedia.objects.select_related("content"), to_attr="prefetched_contents", ), Prefetch( "published__version__componentversion__componentversioncontent_set", - queryset=ComponentVersionContent.objects.select_related("content"), + queryset=ComponentVersionMedia.objects.select_related("content"), to_attr="prefetched_contents", ), ) @@ -374,11 +374,11 @@ def create_zip(self, path: str) -> None: # Get content data associated with this version contents: QuerySet[ - ComponentVersionContent + ComponentVersionMedia ] = component_version.prefetched_contents # type: ignore[attr-defined] for component_version_content in contents: - content: Content = component_version_content.content + content: Media = component_version_content.content # Important: The component_version_content.key contains implicitly # the file name and the file extension diff --git a/src/openedx_content/applets/components/admin.py b/src/openedx_content/applets/components/admin.py index e85149c4a..713a9bad1 100644 --- a/src/openedx_content/applets/components/admin.py +++ b/src/openedx_content/applets/components/admin.py @@ -11,7 +11,7 @@ from openedx_django_lib.admin_utils import ReadOnlyModelAdmin -from .models import Component, ComponentVersion, ComponentVersionContent +from .models import Component, ComponentVersion, ComponentVersionMedia class ComponentVersionInline(admin.TabularInline): @@ -134,7 +134,7 @@ def format_text_for_admin_display(text: str) -> SafeText: ) -def content_preview(cvc_obj: ComponentVersionContent) -> SafeText: +def content_preview(cvc_obj: ComponentVersionMedia) -> SafeText: """ Get the HTML to display a preview of the given ComponentVersionContent """ diff --git a/src/openedx_content/applets/components/api.py b/src/openedx_content/applets/components/api.py index 098c78689..e30fdc5cb 100644 --- a/src/openedx_content/applets/components/api.py +++ b/src/openedx_content/applets/components/api.py @@ -25,7 +25,7 @@ from ..media import api as media_api from ..publishing import api as publishing_api -from .models import Component, ComponentType, ComponentVersion, ComponentVersionContent +from .models import Component, ComponentType, ComponentVersion, ComponentVersionMedia # The public API that will be re-exported by openedx_content.api # is listed in the __all__ entries below. Internal helper functions that are @@ -265,7 +265,7 @@ def create_next_component_version( content_pk = content.pk else: content_pk = content_pk_or_bytes - ComponentVersionContent.objects.create( + ComponentVersionMedia.objects.create( content_id=content_pk, component_version=component_version, key=key, @@ -276,11 +276,11 @@ def create_next_component_version( # Now copy any old associations that existed, as long as they aren't # in conflict with the new stuff or marked for deletion. - last_version_content_mapping = ComponentVersionContent.objects \ + last_version_content_mapping = ComponentVersionMedia.objects \ .filter(component_version=last_version) for cvrc in last_version_content_mapping: if cvrc.key not in content_to_replace: - ComponentVersionContent.objects.create( + ComponentVersionMedia.objects.create( content_id=cvrc.content_id, component_version=component_version, key=cvrc.key, @@ -454,7 +454,7 @@ def look_up_component_version_content( component_key: str, version_num: int, key: Path, -) -> ComponentVersionContent: +) -> ComponentVersionMedia: """ Look up ComponentVersionContent by human readable keys. @@ -470,7 +470,7 @@ def look_up_component_version_content( & Q(component_version__publishable_entity_version__version_num=version_num) & Q(key=key) ) - return ComponentVersionContent.objects \ + return ComponentVersionMedia.objects \ .select_related( "content", "content__media_type", @@ -485,7 +485,7 @@ def create_component_version_content( content_id: int, /, key: str, -) -> ComponentVersionContent: +) -> ComponentVersionMedia: """ Add a Content to the given ComponentVersion @@ -503,7 +503,7 @@ def create_component_version_content( ) key = key.lstrip('/') - cvrc, _created = ComponentVersionContent.objects.get_or_create( + cvrc, _created = ComponentVersionMedia.objects.get_or_create( component_version_id=component_version_id, content_id=content_id, key=key, @@ -622,7 +622,7 @@ def _error_header(error: AssetError) -> dict[str, str]: # Check: Does the ComponentVersion have the requested asset (Content)? try: cv_content = component_version.componentversioncontent_set.get(key=asset_path) - except ComponentVersionContent.DoesNotExist: + except ComponentVersionMedia.DoesNotExist: logger.error(f"ComponentVersion {component_version_uuid} has no asset {asset_path}") info_headers.update( _error_header(AssetError.ASSET_PATH_NOT_FOUND_FOR_COMPONENT_VERSION) diff --git a/src/openedx_content/applets/components/models.py b/src/openedx_content/applets/components/models.py index 669824093..a8f78008c 100644 --- a/src/openedx_content/applets/components/models.py +++ b/src/openedx_content/applets/components/models.py @@ -24,14 +24,14 @@ from openedx_django_lib.fields import case_sensitive_char_field, key_field from openedx_django_lib.managers import WithRelationsManager -from ..media.models import Content +from ..media.models import Media from ..publishing.models import LearningPackage, PublishableEntityMixin, PublishableEntityVersionMixin __all__ = [ "ComponentType", "Component", "ComponentVersion", - "ComponentVersionContent", + "ComponentVersionMedia", ] @@ -212,8 +212,8 @@ class ComponentVersion(PublishableEntityVersionMixin): # The contents hold the actual interesting data associated with this # ComponentVersion. - contents: models.ManyToManyField[Content, ComponentVersionContent] = models.ManyToManyField( - Content, + contents: models.ManyToManyField[Media, ComponentVersionMedia] = models.ManyToManyField( + Media, through="ComponentVersionContent", related_name="component_versions", ) @@ -223,7 +223,7 @@ class Meta: verbose_name_plural = "Component Versions" -class ComponentVersionContent(models.Model): +class ComponentVersionMedia(models.Model): """ Determines the Content for a given ComponentVersion. @@ -240,7 +240,7 @@ class ComponentVersionContent(models.Model): """ component_version = models.ForeignKey(ComponentVersion, on_delete=models.CASCADE) - content = models.ForeignKey(Content, on_delete=models.RESTRICT) + content = 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 diff --git a/src/openedx_content/applets/media/admin.py b/src/openedx_content/applets/media/admin.py index 50bf9fc57..4c044da6d 100644 --- a/src/openedx_content/applets/media/admin.py +++ b/src/openedx_content/applets/media/admin.py @@ -8,10 +8,10 @@ from openedx_django_lib.admin_utils import ReadOnlyModelAdmin -from .models import Content +from .models import Media -@admin.register(Content) +@admin.register(Media) class ContentAdmin(ReadOnlyModelAdmin): """ Django admin for Content model @@ -40,13 +40,13 @@ class ContentAdmin(ReadOnlyModelAdmin): search_fields = ("hash_digest",) @admin.display(description="OS Path") - def os_path(self, content: Content): + def os_path(self, content: Media): return content.os_path() or "" - def path(self, content: Content): + def path(self, content: Media): return content.path if content.has_file else "" - def text_preview(self, content: Content): + def text_preview(self, content: Media): if not content.text: return "" return format_html( @@ -54,7 +54,7 @@ def text_preview(self, content: Content): content.text, ) - def image_preview(self, content: Content): + def image_preview(self, content: Media): """ Return HTML for an image, if that is the underlying Content. diff --git a/src/openedx_content/applets/media/api.py b/src/openedx_content/applets/media/api.py index 31f523c6d..60efd0013 100644 --- a/src/openedx_content/applets/media/api.py +++ b/src/openedx_content/applets/media/api.py @@ -14,7 +14,7 @@ from openedx_django_lib.fields import create_hash_digest -from .models import Content, MediaType +from .models import Media, MediaType # The public API that will be re-exported by openedx_content.api # is listed in the __all__ entries below. Internal helper functions that are @@ -67,7 +67,7 @@ def get_or_create_media_type(mime_type: str) -> MediaType: return media_type -def get_content(content_id: int, /) -> Content: +def get_content(content_id: int, /) -> Media: """ Get a single Content object by its ID. @@ -77,7 +77,7 @@ def get_content(content_id: int, /) -> Content: include this function anyway because it's tiny to write and it's better than someone using a get_or_create_* function when they really just want to get. """ - return Content.objects.get(id=content_id) + return Media.objects.get(id=content_id) def get_or_create_text_content( @@ -87,7 +87,7 @@ def get_or_create_text_content( text: str, created: datetime, create_file: bool = False, -) -> Content: +) -> Media: """ Get or create a Content entry with text data stored in the database. @@ -110,13 +110,13 @@ def get_or_create_text_content( with atomic(): try: - content = Content.objects.get( + content = Media.objects.get( learning_package_id=learning_package_id, media_type_id=media_type_id, hash_digest=hash_digest, ) - except Content.DoesNotExist: - content = Content( + except Media.DoesNotExist: + content = Media( learning_package_id=learning_package_id, media_type_id=media_type_id, hash_digest=hash_digest, @@ -140,7 +140,7 @@ def get_or_create_file_content( /, data: bytes, created: datetime, -) -> Content: +) -> Media: """ Get or create a Content with data stored in a file storage backend. @@ -153,13 +153,13 @@ def get_or_create_file_content( hash_digest = create_hash_digest(data) with atomic(): try: - content = Content.objects.get( + content = Media.objects.get( learning_package_id=learning_package_id, media_type_id=media_type_id, hash_digest=hash_digest, ) - except Content.DoesNotExist: - content = Content( + except Media.DoesNotExist: + content = Media( learning_package_id=learning_package_id, media_type_id=media_type_id, hash_digest=hash_digest, @@ -176,7 +176,7 @@ def get_or_create_file_content( return content -def get_content_info_headers(content: Content) -> dict[str, str]: +def get_content_info_headers(content: Media) -> dict[str, str]: """ Return HTTP headers that are specific to this Content. diff --git a/src/openedx_content/applets/media/models.py b/src/openedx_content/applets/media/models.py index 23f9409dc..bce9e5a3e 100644 --- a/src/openedx_content/applets/media/models.py +++ b/src/openedx_content/applets/media/models.py @@ -30,14 +30,14 @@ __all__ = [ "MediaType", - "Content", + "Media", ] @cache def get_storage() -> Storage: """ - Return the Storage instance for our Content file persistence. + Return the Storage instance for our Media file persistence. This will first search for an OPENEDX_LEARNING config dictionary and return a Storage subclass based on that configuration. @@ -63,10 +63,10 @@ def get_storage() -> Storage: class MediaType(models.Model): """ - Stores Media types for use by Content models. + Stores Media types for use by Media models. This is the same as MIME types (the IANA renamed MIME Types to Media Types). - We don't pre-populate this table, so APIs that add Content must ensure that + We don't pre-populate this table, so APIs that add Media must ensure that the desired Media Type exists. Media types are written as {type}/{sub_type}+{suffix}, where suffixes are @@ -77,9 +77,9 @@ class MediaType(models.Model): * image/svg+xml * application/vnd.openedx.xblock.v1.problem+xml - We have this as a separate model (instead of a field on Content) because: + We have this as a separate model (instead of a field on Media) because: - 1. We can save a lot on storage and indexing for Content if we're just + 1. We can save a lot on storage and indexing for Media if we're just storing foreign key references there, rather than the entire content string to be indexed. This is especially relevant for our (long) custom types like "application/vnd.openedx.xblock.v1.problem+xml". @@ -87,9 +87,9 @@ class MediaType(models.Model): "application/javascript". Also, we will be using a fair number of "vnd." style of custom content types, and we may want the flexibility of changing that without having to worry about migrating millions of rows of - Content. + Media. """ - # We're going to have many foreign key references from Content into this + # We're going to have many foreign key references from Media into this # model, and we don't need to store those as 8-byte BigAutoField, as is the # default for this app. It's likely that a SmallAutoField would work, but I # can just barely imagine using more than 32K Media types if we have a bunch @@ -135,27 +135,27 @@ def __str__(self) -> str: return base -class Content(models.Model): +class Media(models.Model): """ - This is the most primitive piece of content data. + This is the most primitive piece of content. This model serves to lookup, de-duplicate, and store text and files. A piece - of Content is identified purely by its data, the media type, and the + of Media is identified purely by its data, the media type, and the LearningPackage it is associated with. It has no version or file name metadata associated with it. It exists to be a dumb blob of data that higher level models like ComponentVersions can assemble together. # In-model Text vs. File - That being said, the Content model does have some complexity to accomodate + That being said, the Media model does have some complexity to accomodate different access patterns that we have in our app. In particular, it can store data in two ways: the ``text`` field and a file (``has_file=True``) - A Content object must use at least one of these methods, but can use both if + A Media object must use at least one of these methods, but can use both if it's appropriate. Use the ``text`` field when: * the content is a relatively small (< 50K, usually much less) piece of text - * you want to do be able to query up update across many rows at once + * you want to do be able to query across many rows at once * low, predictable latency is important Use file storage when: @@ -170,36 +170,36 @@ class Content(models.Model): # Association with a LearningPackage - Content is associated with a specific LearningPackage. Doing so allows us to + Media is associated with a specific LearningPackage. Doing so allows us to more easily query for how much storge space a specific LearningPackage (likely a library) is using, and to clean up unused data. - When we get to borrowing Content across LearningPackages, it's likely that + When we get to borrowing Media across LearningPackages, it's likely that we will want to copy them. That way, even if the originating LearningPackage is deleted, it won't break other LearningPackages that are making use if it. # Media Types, and file duplication - Content is almost 1:1 with the files that it pushes to a storage backend, + Media is almost 1:1 with the files that it pushes to a storage backend, but not quite. The file locations are generated purely as a product of the - LearningPackage UUID and the Content's ``hash_digest``, but Content also + LearningPackage UUID and the Media's ``hash_digest``, but Media also takes into account the ``media_type``. - For example, say we had a Content with the following data: + For example, say we had a Media with the following data: ["hello", "world"] That is legal syntax for both JSON and YAML. If you want to attach some YAML-specific metadata in a new model, you could make it 1:1 with the - Content that matched the "application/yaml" media type. The YAML and JSON - versions of this data would be two separate Content rows that would share + Media that matched the "application/yaml" media type. The YAML and JSON + versions of this data would be two separate Media rows that would share the same ``hash_digest`` value. If they both stored a file, they would be pointing to the same file location. If they only used the ``text`` field, - then that value would be duplicated across the two separate Content rows. + then that value would be duplicated across the two separate Media rows. The alternative would have been to associate media types at the level where this data was being added to a ComponentVersion, but that would have added - more complexity. Right now, you could make an ImageContent 1:1 model that + more complexity. Right now, you could make an ImageMedia 1:1 model that analyzed images and created metatdata entries for them (dimensions, GPS) without having to understand how ComponentVerisons work. @@ -213,14 +213,14 @@ class Content(models.Model): # Immutability - From the outside, Content should appear immutable. Since the Content is + From the outside, Media should appear immutable. Since the Media is looked up by a hash of its data, a change in the data means that we should - look up the hash value of that new data and create a new Content if we don't + look up the hash value of that new data and create a new Media if we don't find a match. - That being said, the Content model has different ways of storing that data, - and that is mutable. We could decide that a certain type of Content should - be optimized to store its text in the table. Or that a content type that we + That being said, the Media model has different ways of storing that data, + and that is mutable. We could decide that a certain type of Media should + be optimized to store its text in the table. Or that a media type that we had previously only stored as text now also needs to be stored on in the file storage backend so that it can be made available to be downloaded. These operations would be done as data migrations. @@ -228,8 +228,8 @@ class Content(models.Model): # Extensibility Third-party apps are encouraged to create models that have a OneToOneField - relationship with Content. For instance, an ImageContent model might join - 1:1 with all Content that has image/* media types, and provide additional + relationship with Media. For instance, an ImageMedia model might join + 1:1 with all Media that has image/* media types, and provide additional metadata for that data. """ # Max size of the file. @@ -240,7 +240,7 @@ class Content(models.Model): # could be as much as 200K of data if we had nothing but emojis. MAX_TEXT_LENGTH = 50_000 - objects: models.Manager[Content] = WithRelationsManager('media_type') + objects: models.Manager[Media] = WithRelationsManager('media_type') learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) @@ -260,17 +260,17 @@ class Content(models.Model): # file or not. When storing just a file, we hash the bytes in the file. hash_digest = hash_field() - # Do we have file data stored for this Content in our file storage backend? + # Do we have file data stored for this Media in our file storage backend? # We use has_file instead of a FileField because it's more space efficient. - # The location of a Content's file data is derivable from the Learning - # Package's UUID and the hash of the Content. There's no need to waste that + # The location of a Media's file data is derivable from the Learning + # Package's UUID and the hash of the Media. There's no need to waste that # space to encode it in every row. has_file = models.BooleanField() - # The ``text`` field contains the text representation of the Content, if + # The ``text`` field contains the text representation of the Media, if # it is available. A blank value means means that we are storing text for - # this Content, and that text happens to be an empty string. A null value - # here means that we are not storing any text here, and the Content exists + # this Media, and that text happens to be an empty string. A null value + # here means that we are not storing any text here, and the Media exists # only in file form. It is an error for ``text`` to be None and ``has_file`` # to be False, since that would mean we haven't stored data anywhere at all. # @@ -287,7 +287,7 @@ class Content(models.Model): } ) - # This should be manually set so that multiple Content rows being set in + # This should be manually set so that multiple Media rows being set in # the same transaction are created with the same timestamp. The timestamp # should be UTC. created = manual_date_time_field() @@ -295,7 +295,7 @@ class Content(models.Model): @cached_property def mime_type(self) -> str: """ - The IANA media type (a.k.a. MIME type) of the Content, in string form. + The IANA media type (a.k.a. MIME type) of the Media, in string form. MIME types reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types @@ -316,7 +316,7 @@ def path(self): def os_path(self): """ - The full OS path for the underlying file for this Content. + The full OS path for the underlying file for this Media. This will not be supported by all Storage class types. @@ -334,7 +334,7 @@ def read_file(self) -> File: Get a File object that has been open for reading. We intentionally don't expose an `open()` call where callers can open - this file in write mode. Writing a Content file should happen at most + this file in write mode. Writing a Media file should happen at most once, and the logic is not obvious (see ``write_file``). At the end of the day, the caller can close the returned File and reopen @@ -347,23 +347,23 @@ def write_file(self, file: File) -> None: """ Write file contents to the file storage backend. - This function does nothing if the file already exists. Note that Content + This function does nothing if the file already exists. Note that Media is supposed to be immutable, so this should normally only be called once - for a given Content row. + for a given Media row. """ storage = get_storage() # There are two reasons why a file might already exist even if the the - # Content row is new: + # Media row is new: # # 1. We tried adding the file earlier, but an error rolled back the # state of the database. The file storage system isn't covered by any # sort of transaction semantics, so it won't get rolled back. # - # 2. The Content is of a different MediaType. The same exact bytes can - # be two logically separate Content entries if they are different file - # types. This lets other models add data to Content via 1:1 relations by - # ContentType (e.g. all SRT files). This is definitely an edge case. + # 2. The Media is of a different MediaType. The same exact bytes can + # be two logically separate Media entries if they are different file + # types. This lets other models add data to Media via 1:1 relations by + # MediaType (e.g. all SRT files). This is definitely an edge case. # # 3. Similar to (2), but only part of the file was written before an # error occurred. This seems unlikely, but possible if the underlying @@ -382,12 +382,12 @@ def clean(self): """ Make sure we're actually storing *something*. - If this Content has neither a file or text data associated with it, + If this Media has neither a file or text data associated with it, it's in a broken/useless state and shouldn't be saved. """ if (not self.has_file) and (self.text is None): raise ValidationError( - f"Content {self.pk} with hash {self.hash_digest} must either " + f"Media {self.pk} with hash {self.hash_digest} must either " "set a string value for 'text', or it must set has_file=True " "(or both)." ) @@ -407,11 +407,11 @@ class Meta: ] indexes = [ # LearningPackage (reverse) Size Index: - # * Find the largest Content entries. + # * Find the largest Media entries. models.Index( fields=["learning_package", "-size"], name="oel_content_idx_lp_rsize", ), ] - verbose_name = "Content" - verbose_name_plural = "Contents" + verbose_name = "Media" + verbose_name_plural = "Medias" diff --git a/tests/openedx_content/applets/backup_restore/test_backup.py b/tests/openedx_content/applets/backup_restore/test_backup.py index f3d753f29..fa34db861 100644 --- a/tests/openedx_content/applets/backup_restore/test_backup.py +++ b/tests/openedx_content/applets/backup_restore/test_backup.py @@ -13,7 +13,7 @@ from openedx_content import api from openedx_content.applets.backup_restore.zipper import LearningPackageZipper -from openedx_content.models_api import Collection, Component, Content, LearningPackage, PublishableEntity +from openedx_content.models_api import Collection, Component, Media, LearningPackage, PublishableEntity User = get_user_model() @@ -32,7 +32,7 @@ class LpDumpCommandTestCase(TestCase): published_component: Component published_component2: Component draft_component: Component - html_asset_content: Content + html_asset_content: Media collection: Collection @classmethod diff --git a/tests/openedx_content/applets/components/test_assets.py b/tests/openedx_content/applets/components/test_assets.py index 78d57fc34..d6d279bf9 100644 --- a/tests/openedx_content/applets/components/test_assets.py +++ b/tests/openedx_content/applets/components/test_assets.py @@ -26,9 +26,9 @@ class AssetTestCase(TestCase): component: components_api.Component component_version: components_api.ComponentVersion - problem_content: media_api.Content - python_source_asset: media_api.Content - html_asset_content: media_api.Content + problem_content: media_api.Media + python_source_asset: media_api.Media + html_asset_content: media_api.Media learning_package: LearningPackage now: datetime From 12eac978ee6d2780b807e29db2e8d4cb9ded519e Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 20 Feb 2026 11:29:52 -0500 Subject: [PATCH 04/12] temp: convert relations references --- .../applets/backup_restore/zipper.py | 16 +++--- .../applets/components/admin.py | 32 ++++++------ src/openedx_content/applets/components/api.py | 36 ++++++------- .../applets/components/models.py | 23 +++++---- src/openedx_content/applets/media/api.py | 4 +- .../commands/add_assets_to_component.py | 2 +- .../0003_rename_content_to_media.py | 51 +++++++++++++++++++ .../applets/backup_restore/test_restore.py | 2 +- .../applets/components/test_api.py | 50 +++++++++--------- tests/openedx_tagging/test_views.py | 4 +- 10 files changed, 138 insertions(+), 82 deletions(-) create mode 100644 src/openedx_content/migrations/0003_rename_content_to_media.py diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 568131f2c..73edf8b71 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -191,14 +191,14 @@ def get_publishable_entities(self) -> QuerySet[PublishableEntity]: # especially with large libraries (up to 100K items), # which is too large for this type of prefetch. Prefetch( - "draft__version__componentversion__componentversioncontent_set", - queryset=ComponentVersionMedia.objects.select_related("content"), - to_attr="prefetched_contents", + "draft__version__componentversion__componentversionmedia_set", + queryset=ComponentVersionMedia.objects.select_related("media"), + to_attr="prefetched_media", ), Prefetch( - "published__version__componentversion__componentversioncontent_set", - queryset=ComponentVersionMedia.objects.select_related("content"), - to_attr="prefetched_contents", + "published__version__componentversion__componentversionmedia_set", + queryset=ComponentVersionMedia.objects.select_related("media"), + to_attr="prefetched_media", ), ) .order_by("key") @@ -375,10 +375,10 @@ def create_zip(self, path: str) -> None: # Get content data associated with this version contents: QuerySet[ ComponentVersionMedia - ] = component_version.prefetched_contents # type: ignore[attr-defined] + ] = component_version.prefetched_media # type: ignore[attr-defined] for component_version_content in contents: - content: Media = component_version_content.content + content: Media = component_version_content.media # Important: The component_version_content.key contains implicitly # the file name and the file extension diff --git a/src/openedx_content/applets/components/admin.py b/src/openedx_content/applets/components/admin.py index 713a9bad1..824e62ad7 100644 --- a/src/openedx_content/applets/components/admin.py +++ b/src/openedx_content/applets/components/admin.py @@ -54,14 +54,14 @@ class ContentInline(admin.TabularInline): """ Django admin configuration for Content """ - model = ComponentVersion.contents.through + model = ComponentVersion.media.through def get_queryset(self, request): queryset = super().get_queryset(request) return queryset.select_related( - "content", - "content__learning_package", - "content__media_type", + "media", + "media__learning_package", + "media__media_type", "component_version", "component_version__publishable_entity_version", "component_version__component", @@ -74,7 +74,7 @@ def get_queryset(self, request): "rendered_data", ] readonly_fields = [ - "content", + "media", "key", "format_size", "rendered_data", @@ -82,14 +82,14 @@ def get_queryset(self, request): extra = 0 def has_file(self, cvc_obj): - return cvc_obj.content.has_file + return cvc_obj.media.has_file def rendered_data(self, cvc_obj): - return content_preview(cvc_obj) + return media_preview(cvc_obj) @admin.display(description="Size") def format_size(self, cvc_obj): - return filesizeformat(cvc_obj.content.size) + return filesizeformat(cvc_obj.media.size) @admin.register(ComponentVersion) @@ -103,7 +103,7 @@ class ComponentVersionAdmin(ReadOnlyModelAdmin): "title", "version_num", "created", - "contents", + "media", ] fields = [ "component", @@ -134,26 +134,26 @@ def format_text_for_admin_display(text: str) -> SafeText: ) -def content_preview(cvc_obj: ComponentVersionMedia) -> SafeText: +def media_preview(cvc_obj: ComponentVersionMedia) -> SafeText: """ Get the HTML to display a preview of the given ComponentVersionContent """ - content_obj = cvc_obj.content + media_obj = cvc_obj.media - if content_obj.media_type.type == "image": + if media_obj.media_type.type == "image": # This base64 encoding looks really goofy and is bad for performance, # but image previews in the admin are extremely useful, and this lets us # have them without creating a separate view in Open edX Core. (Keep in # mind that these assets are private, so they cannot be accessed via the # MEDIA_URL like most Django uploaded assets.) - data = content_obj.read_file().read() + data = media_obj.read_file().read() return format_html( '
{}
', - content_obj.mime_type, + media_obj.mime_type, base64.encodebytes(data).decode('utf8'), - content_obj.os_path(), + media_obj.os_path(), ) return format_text_for_admin_display( - content_obj.text or "" + media_obj.text or "" ) diff --git a/src/openedx_content/applets/components/api.py b/src/openedx_content/applets/components/api.py index e30fdc5cb..d0fe505c8 100644 --- a/src/openedx_content/applets/components/api.py +++ b/src/openedx_content/applets/components/api.py @@ -244,13 +244,13 @@ def create_next_component_version( component_id=component_pk, ) # First copy the new stuff over... - for key, content_pk_or_bytes in content_to_replace.items(): - # If the content_pk is None, it means we want to remove the + for key, media_pk_or_bytes in content_to_replace.items(): + # If the media_pk is None, it means we want to remove the # content represented by our key from the next version. Otherwise, - # we add our key->content_pk mapping to the next version. - if content_pk_or_bytes is not None: - if isinstance(content_pk_or_bytes, bytes): - file_path, file_content = key, content_pk_or_bytes + # we add our key->media_pk mapping to the next version. + if media_pk_or_bytes is not None: + if isinstance(media_pk_or_bytes, bytes): + file_path, file_content = key, media_pk_or_bytes media_type_str, _encoding = mimetypes.guess_type(file_path) # We use "application/octet-stream" as a generic fallback media type, per # RFC 2046: https://datatracker.ietf.org/doc/html/rfc2046 @@ -262,11 +262,11 @@ def create_next_component_version( data=file_content, created=created, ) - content_pk = content.pk + media_pk = content.pk else: - content_pk = content_pk_or_bytes + media_pk = media_pk_or_bytes ComponentVersionMedia.objects.create( - content_id=content_pk, + media_id=media_pk, component_version=component_version, key=key, ) @@ -281,7 +281,7 @@ def create_next_component_version( for cvrc in last_version_content_mapping: if cvrc.key not in content_to_replace: ComponentVersionMedia.objects.create( - content_id=cvrc.content_id, + media_id=cvrc.media_id, component_version=component_version, key=cvrc.key, ) @@ -482,7 +482,7 @@ def look_up_component_version_content( def create_component_version_content( component_version_id: int, - content_id: int, + media_id: int, /, key: str, ) -> ComponentVersionMedia: @@ -499,13 +499,13 @@ def create_component_version_content( logger.warning( "Absolute paths are not supported: " f"removed leading '/' from ComponentVersion {component_version_id} " - f"content key: {repr(key)} (content_id: {content_id})" + f"content key: {repr(key)} (media_id: {media_id})" ) key = key.lstrip('/') cvrc, _created = ComponentVersionMedia.objects.get_or_create( component_version_id=component_version_id, - content_id=content_id, + media_id=media_id, key=key, ) return cvrc @@ -621,7 +621,7 @@ def _error_header(error: AssetError) -> dict[str, str]: # Check: Does the ComponentVersion have the requested asset (Content)? try: - cv_content = component_version.componentversioncontent_set.get(key=asset_path) + cv_content = component_version.componentversionmedia_set.get(key=asset_path) except ComponentVersionMedia.DoesNotExist: logger.error(f"ComponentVersion {component_version_uuid} has no asset {asset_path}") info_headers.update( @@ -634,8 +634,8 @@ def _error_header(error: AssetError) -> dict[str, str]: # anyway, but we're explicitly not doing so because streaming large text # fields from the database is less scalable, and we don't want to encourage # that usage pattern. - content = cv_content.content - if not content.has_file: + media = cv_content.media + if not media.has_file: logger.error( f"ComponentVersion {component_version_uuid} has asset {asset_path}, " "but it is not downloadable (has_file=False)." @@ -647,10 +647,10 @@ def _error_header(error: AssetError) -> dict[str, str]: # At this point, we know that there is valid Content that we want to send. # This adds Content-level headers, like the hash/etag and content type. - info_headers.update(media_api.get_content_info_headers(content)) + info_headers.update(media_api.get_content_info_headers(media)) # Recompute redirect headers (reminder: this should never be cached). - redirect_headers = media_api.get_redirect_headers(content.path, public) + redirect_headers = media_api.get_redirect_headers(media.path, public) logger.info( "Asset redirect (uncached metadata): " f"{component_version_uuid}/{asset_path} -> {redirect_headers}" diff --git a/src/openedx_content/applets/components/models.py b/src/openedx_content/applets/components/models.py index a8f78008c..83c79ac44 100644 --- a/src/openedx_content/applets/components/models.py +++ b/src/openedx_content/applets/components/models.py @@ -191,6 +191,11 @@ class Meta: verbose_name = "Component" verbose_name_plural = "Components" + @property + def contents(self): + """Temp backwards compatibility shim.""" + return self.media + def __str__(self) -> str: return f"{self.component_type.namespace}:{self.component_type.name}:{self.local_key}" @@ -200,7 +205,7 @@ class ComponentVersion(PublishableEntityVersionMixin): A particular version of a Component. This holds the content using a M:M relationship with Content via - ComponentVersionContent. + ComponentVersionMedia. """ # This is technically redundant, since we can get this through @@ -210,11 +215,11 @@ class ComponentVersion(PublishableEntityVersionMixin): Component, on_delete=models.CASCADE, related_name="versions" ) - # The contents hold the actual interesting data associated with this + # The media relation holds the actual interesting data associated with this # ComponentVersion. - contents: models.ManyToManyField[Media, ComponentVersionMedia] = models.ManyToManyField( + media: models.ManyToManyField[Media, ComponentVersionMedia] = models.ManyToManyField( Media, - through="ComponentVersionContent", + through="ComponentVersionMedia", related_name="component_versions", ) @@ -240,7 +245,7 @@ class ComponentVersionMedia(models.Model): """ component_version = models.ForeignKey(ComponentVersion, on_delete=models.CASCADE) - content = models.ForeignKey(Media, on_delete=models.RESTRICT) + 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 @@ -261,11 +266,11 @@ class Meta: ] indexes = [ models.Index( - fields=["content", "component_version"], - name="oel_cvcontent_c_cv", + fields=["media", "component_version"], + name="oel_cvmedia_c_cv", ), models.Index( - fields=["component_version", "content"], - name="oel_cvcontent_cv_d", + fields=["component_version", "media"], + name="oel_cvmedia_cv_d", ), ] diff --git a/src/openedx_content/applets/media/api.py b/src/openedx_content/applets/media/api.py index 60efd0013..ee3e55334 100644 --- a/src/openedx_content/applets/media/api.py +++ b/src/openedx_content/applets/media/api.py @@ -67,7 +67,7 @@ def get_or_create_media_type(mime_type: str) -> MediaType: return media_type -def get_content(content_id: int, /) -> Media: +def get_content(media_id: int, /) -> Media: """ Get a single Content object by its ID. @@ -77,7 +77,7 @@ def get_content(content_id: int, /) -> Media: include this function anyway because it's tiny to write and it's better than someone using a get_or_create_* function when they really just want to get. """ - return Media.objects.get(id=content_id) + return Media.objects.get(id=media_id) def get_or_create_text_content( 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 aafbb7540..86c490fce 100644 --- a/src/openedx_content/management/commands/add_assets_to_component.py +++ b/src/openedx_content/management/commands/add_assets_to_component.py @@ -82,5 +82,5 @@ def handle(self, *args, **options): f"Created v{next_version.version_num} of " f"{next_version.component.key} ({next_version.uuid}):" ) - for cvc in next_version.componentversioncontent_set.all(): + for cvc in next_version.componentversionmedia_set.all(): self.stdout.write(f"- {cvc.key} ({cvc.uuid})") diff --git a/src/openedx_content/migrations/0003_rename_content_to_media.py b/src/openedx_content/migrations/0003_rename_content_to_media.py new file mode 100644 index 000000000..25d6df50d --- /dev/null +++ b/src/openedx_content/migrations/0003_rename_content_to_media.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.11 on 2026-02-19 20:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('openedx_content', '0002_rename_tables_to_openedx_content'), + ] + + operations = [ + migrations.RenameModel( + old_name='ComponentVersionContent', + new_name='ComponentVersionMedia', + ), + migrations.RenameModel( + old_name='Content', + new_name='Media', + ), + migrations.AlterModelOptions( + name='media', + options={'verbose_name': 'Media', 'verbose_name_plural': 'Medias'}, + ), + migrations.RemoveIndex( + model_name='componentversionmedia', + name='oel_cvcontent_c_cv', + ), + migrations.RemoveIndex( + model_name='componentversionmedia', + name='oel_cvcontent_cv_d', + ), + migrations.RenameField( + model_name='componentversion', + old_name='contents', + new_name='media', + ), + migrations.RenameField( + model_name='componentversionmedia', + old_name='content', + new_name='media', + ), + migrations.AddIndex( + model_name='componentversionmedia', + index=models.Index(fields=['media', 'component_version'], name='oel_cvmedia_c_cv'), + ), + migrations.AddIndex( + model_name='componentversionmedia', + index=models.Index(fields=['component_version', 'media'], name='oel_cvmedia_cv_d'), + ), + ] diff --git a/tests/openedx_content/applets/backup_restore/test_restore.py b/tests/openedx_content/applets/backup_restore/test_restore.py index 6d334fafa..88419cd29 100644 --- a/tests/openedx_content/applets/backup_restore/test_restore.py +++ b/tests/openedx_content/applets/backup_restore/test_restore.py @@ -117,7 +117,7 @@ def verify_components(self, lp): assert draft_version.created_by.username == "lp_user" assert published_version is None # Get the content associated with this component - contents = draft_version.componentversion.contents.all() + contents = draft_version.componentversion.media.all() content = contents.first() if contents.exists() else None assert content is not None assert " Date: Fri, 20 Feb 2026 12:32:45 -0500 Subject: [PATCH 05/12] temp: rename content -> media api fns --- .../management/commands/load_components.py | 4 +- .../applets/backup_restore/zipper.py | 2 +- src/openedx_content/applets/components/api.py | 4 +- .../applets/components/models.py | 4 +- src/openedx_content/applets/media/api.py | 64 +++++++++---------- .../applets/backup_restore/test_backup.py | 4 +- .../applets/components/test_api.py | 10 +-- .../applets/components/test_assets.py | 6 +- .../applets/media/test_file_storage.py | 2 +- tests/openedx_tagging/test_views.py | 4 +- 10 files changed, 52 insertions(+), 52 deletions(-) diff --git a/olx_importer/management/commands/load_components.py b/olx_importer/management/commands/load_components.py index 688ba97c6..a0118cb88 100644 --- a/olx_importer/management/commands/load_components.py +++ b/olx_importer/management/commands/load_components.py @@ -114,7 +114,7 @@ def create_content(self, static_local_path, now, component_version): logger.warning(f' Static reference not found: "{real_path}"') return # Might as well bail if we can't find the file. - content = content_api.get_or_create_file_content( + content = content_api.get_or_create_file_media( self.learning_package.id, data=data_bytes, mime_type=mime_type, @@ -163,7 +163,7 @@ def import_block_type(self, block_type_name, now): # , publish_log_entry): # Create the Content entry for the raw data... text = xml_file_path.read_text('utf-8') - text_content, _created = content_api.get_or_create_text_content( + text_content, _created = content_api.get_or_create_text_media( self.learning_package.id, text=text, mime_type=f"application/vnd.openedx.xblock.v1.{block_type_name}+xml", diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 73edf8b71..aa07cde85 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -985,7 +985,7 @@ def _resolve_static_files( # storing the value as a content instance if not self.learning_package_id: raise ValueError("learning_package_id must be set before resolving static files.") - text_content = media_api.get_or_create_text_content( + text_content = media_api.get_or_create_text_media( self.learning_package_id, media_api.get_or_create_media_type(f"application/vnd.openedx.xblock.v1.{block_type}+xml").id, text=content_bytes.decode("utf-8"), diff --git a/src/openedx_content/applets/components/api.py b/src/openedx_content/applets/components/api.py index d0fe505c8..c37dee707 100644 --- a/src/openedx_content/applets/components/api.py +++ b/src/openedx_content/applets/components/api.py @@ -256,7 +256,7 @@ def create_next_component_version( # RFC 2046: https://datatracker.ietf.org/doc/html/rfc2046 media_type_str = media_type_str or "application/octet-stream" media_type = media_api.get_or_create_media_type(media_type_str) - content = media_api.get_or_create_file_content( + content = media_api.get_or_create_file_media( component.learning_package.id, media_type.id, data=file_content, @@ -647,7 +647,7 @@ def _error_header(error: AssetError) -> dict[str, str]: # At this point, we know that there is valid Content that we want to send. # This adds Content-level headers, like the hash/etag and content type. - info_headers.update(media_api.get_content_info_headers(media)) + info_headers.update(media_api.get_media_info_headers(media)) # Recompute redirect headers (reminder: this should never be cached). redirect_headers = media_api.get_redirect_headers(media.path, public) diff --git a/src/openedx_content/applets/components/models.py b/src/openedx_content/applets/components/models.py index 83c79ac44..1e2ac75d1 100644 --- a/src/openedx_content/applets/components/models.py +++ b/src/openedx_content/applets/components/models.py @@ -204,7 +204,7 @@ class ComponentVersion(PublishableEntityVersionMixin): """ A particular version of a Component. - This holds the content using a M:M relationship with Content via + This holds the media using a M:M relationship with Content via ComponentVersionMedia. """ @@ -257,7 +257,7 @@ class ComponentVersionMedia(models.Model): class Meta: constraints = [ # Uniqueness is only by ComponentVersion and key. If for some reason - # a ComponentVersion wants to associate the same piece of content + # a ComponentVersion wants to associate the same piece of Media # with two different identifiers, that is permitted. models.UniqueConstraint( fields=["component_version", "key"], diff --git a/src/openedx_content/applets/media/api.py b/src/openedx_content/applets/media/api.py index ee3e55334..c588f8a3f 100644 --- a/src/openedx_content/applets/media/api.py +++ b/src/openedx_content/applets/media/api.py @@ -23,10 +23,10 @@ # to be callable only by other apps in the authoring package. __all__ = [ "get_or_create_media_type", - "get_content", - "get_content_info_headers", - "get_or_create_text_content", - "get_or_create_file_content", + "get_media", + "get_media_info_headers", + "get_or_create_text_media", + "get_or_create_file_media", ] @@ -67,12 +67,12 @@ def get_or_create_media_type(mime_type: str) -> MediaType: return media_type -def get_content(media_id: int, /) -> Media: +def get_media(media_id: int, /) -> Media: """ - Get a single Content object by its ID. + Get a single Media object by its ID. - Content is always attached to something when it's created, like to a - ComponentVersion. That means the "right" way to access a Content is almost + Media is always attached to something when it's created, like to a + ComponentVersion. That means the "right" way to access a Media is almost always going to be via those relations and not via this function. But I include this function anyway because it's tiny to write and it's better than someone using a get_or_create_* function when they really just want to get. @@ -80,7 +80,7 @@ def get_content(media_id: int, /) -> Media: return Media.objects.get(id=media_id) -def get_or_create_text_content( +def get_or_create_text_media( learning_package_id: int, media_type_id: int, /, @@ -89,7 +89,7 @@ def get_or_create_text_content( create_file: bool = False, ) -> Media: """ - Get or create a Content entry with text data stored in the database. + Get or create a Media entry with text data stored in the database. Use this when you want to create relatively small chunks of text that need to be accessed quickly, especially if you're pulling back multiple rows at @@ -110,13 +110,13 @@ def get_or_create_text_content( with atomic(): try: - content = Media.objects.get( + media = Media.objects.get( learning_package_id=learning_package_id, media_type_id=media_type_id, hash_digest=hash_digest, ) except Media.DoesNotExist: - content = Media( + media = Media( learning_package_id=learning_package_id, media_type_id=media_type_id, hash_digest=hash_digest, @@ -125,16 +125,16 @@ def get_or_create_text_content( text=text, has_file=create_file, ) - content.full_clean() - content.save() + media.full_clean() + media.save() if create_file: - content.write_file(ContentFile(text_as_bytes)) + media.write_file(ContentFile(text_as_bytes)) - return content + return media -def get_or_create_file_content( +def get_or_create_file_media( learning_package_id: int, media_type_id: int, /, @@ -142,24 +142,24 @@ def get_or_create_file_content( created: datetime, ) -> Media: """ - Get or create a Content with data stored in a file storage backend. + Get or create a Media with data stored in a file storage backend. Use this function to store non-text data, large data, or data where low latency access is not necessary. Also use this function (or - ``get_or_create_text_content`` with ``create_file=True``) to store any - Content that you want to be downloadable by browsers in the LMS, since the - static asset serving system will only work with file-backed Content. + ``get_or_create_text_media`` with ``create_file=True``) to store any + Media that you want to be downloadable by browsers in the LMS, since the + static asset serving system will only work with file-backed Media. """ hash_digest = create_hash_digest(data) with atomic(): try: - content = Media.objects.get( + media = Media.objects.get( learning_package_id=learning_package_id, media_type_id=media_type_id, hash_digest=hash_digest, ) except Media.DoesNotExist: - content = Media( + media = Media( learning_package_id=learning_package_id, media_type_id=media_type_id, hash_digest=hash_digest, @@ -168,24 +168,24 @@ def get_or_create_file_content( text=None, has_file=True, ) - content.full_clean() - content.save() + media.full_clean() + media.save() - content.write_file(ContentFile(data)) + media.write_file(ContentFile(data)) - return content + return media -def get_content_info_headers(content: Media) -> dict[str, str]: +def get_media_info_headers(media: Media) -> dict[str, str]: """ - Return HTTP headers that are specific to this Content. + Return HTTP headers that are specific to this Media. This currently only consists of the Content-Type and ETag. These values are safe to cache. """ return { - "Content-Type": str(content.media_type), - "Etag": content.hash_digest, + "Content-Type": str(media.media_type), + "Etag": media.hash_digest, } @@ -229,7 +229,7 @@ def get_redirect_headers( cache_directive = "private" # This only stays on the user's browser, so cache for a whole day. This - # is okay to do because Content data is typically immutable–i.e. if an + # is okay to do because Media data is typically immutable–i.e. if an # asset actually changes, the user should be directed to a different URL # for it. max_age = max_age or (60 * 60 * 24) diff --git a/tests/openedx_content/applets/backup_restore/test_backup.py b/tests/openedx_content/applets/backup_restore/test_backup.py index fa34db861..30cfb7726 100644 --- a/tests/openedx_content/applets/backup_restore/test_backup.py +++ b/tests/openedx_content/applets/backup_restore/test_backup.py @@ -100,7 +100,7 @@ def setUpTestData(cls): created=cls.now, ) - new_txt_content = api.get_or_create_text_content( + new_txt_content = api.get_or_create_text_media( cls.learning_package.pk, text_media_type.id, text="This is some data", @@ -129,7 +129,7 @@ def setUpTestData(cls): created=cls.now, ) - cls.html_asset_content = api.get_or_create_file_content( + cls.html_asset_content = api.get_or_create_file_media( cls.learning_package.id, html_media_type.id, data=b"hello world!", diff --git a/tests/openedx_content/applets/components/test_api.py b/tests/openedx_content/applets/components/test_api.py index 916d2f6d7..c207107a9 100644 --- a/tests/openedx_content/applets/components/test_api.py +++ b/tests/openedx_content/applets/components/test_api.py @@ -400,7 +400,7 @@ def test_add(self): created=self.now, created_by=None, ) - new_content = media_api.get_or_create_text_content( + new_content = media_api.get_or_create_text_media( self.learning_package.pk, self.text_media_type.id, text="This is some data", @@ -460,19 +460,19 @@ def test_bytes_content(self): assert content_raw_txt.read_file().read() == bytes_content def test_multiple_versions(self): - hello_content = media_api.get_or_create_text_content( + hello_content = media_api.get_or_create_text_media( self.learning_package.id, self.text_media_type.id, text="Hello World!", created=self.now, ) - goodbye_content = media_api.get_or_create_text_content( + goodbye_content = media_api.get_or_create_text_media( self.learning_package.id, self.text_media_type.id, text="Goodbye World!", created=self.now, ) - blank_content = media_api.get_or_create_text_content( + blank_content = media_api.get_or_create_text_media( self.learning_package.id, self.text_media_type.id, text="", @@ -573,7 +573,7 @@ def test_create_multiple_next_versions_and_diff_content(self): python_source_media_type = media_api.get_or_create_media_type( "text/x-python", ) - python_source_asset = media_api.get_or_create_file_content( + python_source_asset = media_api.get_or_create_file_media( self.learning_package.id, python_source_media_type.id, data=b"print('hello world!')", diff --git a/tests/openedx_content/applets/components/test_assets.py b/tests/openedx_content/applets/components/test_assets.py index d6d279bf9..297dbdd0f 100644 --- a/tests/openedx_content/applets/components/test_assets.py +++ b/tests/openedx_content/applets/components/test_assets.py @@ -66,7 +66,7 @@ def setUpTestData(cls) -> None: ) # ProblemBlock content that is stored as text Content, not a file. - cls.problem_content = media_api.get_or_create_text_content( + cls.problem_content = media_api.get_or_create_text_media( cls.learning_package.id, cls.problem_block_media_type.id, text="(pretend problem OLX is here)", @@ -80,7 +80,7 @@ def setUpTestData(cls) -> None: # Python source file, stored as a file. This is hypothetical, as we # don't actually support bundling grader files like this today. - cls.python_source_asset = media_api.get_or_create_file_content( + cls.python_source_asset = media_api.get_or_create_file_media( cls.learning_package.id, cls.python_source_media_type.id, data=b"print('hello world!')", @@ -93,7 +93,7 @@ def setUpTestData(cls) -> None: ) # An HTML file that is student downloadable - cls.html_asset_content = media_api.get_or_create_file_content( + cls.html_asset_content = media_api.get_or_create_file_media( cls.learning_package.id, cls.html_media_type.id, data=b"hello world!", diff --git a/tests/openedx_content/applets/media/test_file_storage.py b/tests/openedx_content/applets/media/test_file_storage.py index 3df99d134..88b71286f 100644 --- a/tests/openedx_content/applets/media/test_file_storage.py +++ b/tests/openedx_content/applets/media/test_file_storage.py @@ -33,7 +33,7 @@ def setUp(self) -> None: title="Content File Storage Test Case Learning Package", ) self.html_media_type = media_api.get_or_create_media_type("text/html") - self.html_content = media_api.get_or_create_file_content( + self.html_content = media_api.get_or_create_file_media( learning_package.id, self.html_media_type.id, data=b"hello world!", diff --git a/tests/openedx_tagging/test_views.py b/tests/openedx_tagging/test_views.py index 0e21b3e46..67b975e06 100644 --- a/tests/openedx_tagging/test_views.py +++ b/tests/openedx_tagging/test_views.py @@ -618,7 +618,7 @@ def test_export_taxonomy(self, output_format, content_type): expected_data = import_export_api.export_tags(taxonomy, ParserFormat.CSV) assert response.headers['Content-Type'] == content_type - assert response.media == expected_data.encode("utf-8") + assert response.content == expected_data.encode("utf-8") @ddt.data( ("csv", "text/csv"), @@ -645,7 +645,7 @@ def test_export_taxonomy_download(self, output_format, content_type): assert response.headers['Content-Type'] == content_type assert response.headers['Content-Disposition'] == f'attachment; filename="{taxonomy.name}.{output_format}"' - assert response.media == expected_data.encode("utf-8") + assert response.content == expected_data.encode("utf-8") def test_export_taxonomy_invalid_param_output_format(self): """ From 6d4c2cefb19eff313d0525595bdec64008642c1c Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 20 Feb 2026 13:59:09 -0500 Subject: [PATCH 06/12] temp: rename many vars from content to media --- .../management/commands/load_components.py | 4 +- .../applets/backup_restore/zipper.py | 52 ++++++------- src/openedx_content/applets/components/api.py | 78 +++++++++---------- .../applets/components/models.py | 5 -- src/openedx_content/applets/media/admin.py | 22 +++--- src/openedx_content/applets/media/models.py | 5 +- .../commands/add_assets_to_component.py | 2 +- src/openedx_core/__init__.py | 2 +- .../applets/backup_restore/test_backup.py | 8 +- .../applets/components/test_api.py | 24 +++--- .../applets/components/test_assets.py | 6 +- .../openedx_content/applets/units/test_api.py | 2 +- 12 files changed, 104 insertions(+), 106 deletions(-) diff --git a/olx_importer/management/commands/load_components.py b/olx_importer/management/commands/load_components.py index a0118cb88..1bbd72da2 100644 --- a/olx_importer/management/commands/load_components.py +++ b/olx_importer/management/commands/load_components.py @@ -120,7 +120,7 @@ def create_content(self, static_local_path, now, component_version): mime_type=mime_type, created=now, ) - content_api.create_component_version_content( + content_api.create_component_version_media( component_version, content.id, key=key, @@ -170,7 +170,7 @@ def import_block_type(self, block_type_name, now): # , publish_log_entry): created=now, ) # Add the OLX source text to the ComponentVersion - content_api.create_component_version_content( + content_api.create_component_version_media( component_version, text_content.pk, key="block.xml", diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index aa07cde85..0a695e7c6 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -372,29 +372,29 @@ def create_zip(self, path: str) -> None: # ------ COMPONENT STATIC CONTENT ------------- component_version: ComponentVersion = version.componentversion - # Get content data associated with this version - contents: QuerySet[ + # Get media data associated with this version + media: QuerySet[ ComponentVersionMedia ] = component_version.prefetched_media # type: ignore[attr-defined] - for component_version_content in contents: - content: Media = component_version_content.media + for component_version_media in media: + media: Media = component_version_media.media - # Important: The component_version_content.key contains implicitly + # Important: The component_version_media.key contains implicitly # the file name and the file extension - file_path = version_folder / component_version_content.key + file_path = version_folder / component_version_media.key - if content.has_file and content.path: + if media.has_file and media.path: # If has_file, we pull it from the file system - with content.read_file() as f: + with media.read_file() as f: file_data = f.read() - elif not content.has_file and content.text: - # Otherwise, we use the text content as the file data - file_data = content.text + elif not media.has_file and media.text: + # Otherwise, we use the text media as the file data + file_data = media.text else: - # If no file and no text, we skip this content + # If no file and no text, we skip this media continue - self.add_file_to_zip(zipf, file_path, file_data, timestamp=content.created) + self.add_file_to_zip(zipf, file_path, file_data, timestamp=media.created) # ------ COLLECTION SERIALIZATION ------------- collections = self.get_collections() @@ -792,13 +792,13 @@ def _save_components(self, learning_package, components, component_static_files) for valid_published in components.get("components_published", []): entity_key = valid_published.pop("entity_key") version_num = valid_published["version_num"] # Should exist, validated earlier - content_to_replace = self._resolve_static_files(version_num, entity_key, component_static_files) + media_to_replace = self._resolve_static_files(version_num, entity_key, component_static_files) self.all_published_entities_versions.add( (entity_key, version_num) ) # Track published version components_api.create_next_component_version( self.components_map_by_key[entity_key].publishable_entity.id, - content_to_replace=content_to_replace, + media_to_replace=media_to_replace, force_version_num=valid_published.pop("version_num", None), created_by=self.user_id, **valid_published @@ -876,14 +876,14 @@ def _save_draft_versions(self, components, containers, component_static_files): version_num = valid_draft["version_num"] # Should exist, validated earlier if self._is_version_already_exists(entity_key, version_num): continue - content_to_replace = self._resolve_static_files(version_num, entity_key, component_static_files) + media_to_replace = self._resolve_static_files(version_num, entity_key, component_static_files) components_api.create_next_component_version( self.components_map_by_key[entity_key].publishable_entity.id, - content_to_replace=content_to_replace, + 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 content + # Drafts can diverge from published, so we allow ignoring previous media # Use case: published v1 had files A, B; draft v2 only has file A - ignore_previous_content=True, + ignore_previous_media=True, created_by=self.user_id, **valid_draft ) @@ -970,7 +970,7 @@ def _resolve_static_files( entity_key: str, static_files_map: dict[str, List[str]] ) -> dict[str, bytes | int]: - """Resolve static file paths into their binary content.""" + """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" @@ -979,21 +979,21 @@ def _resolve_static_files( for static_file in static_files: local_key = static_file.split(f"v{num_version}/")[-1] with self.zipf.open(static_file, "r") as f: - content_bytes = f.read() + media_bytes = f.read() if local_key == "block.xml": # Special handling for block.xml to ensure - # storing the value as a content instance + # storing the value as a media instance if not self.learning_package_id: raise ValueError("learning_package_id must be set before resolving static files.") - text_content = media_api.get_or_create_text_media( + text_media = media_api.get_or_create_text_media( self.learning_package_id, media_api.get_or_create_media_type(f"application/vnd.openedx.xblock.v1.{block_type}+xml").id, - text=content_bytes.decode("utf-8"), + text=media_bytes.decode("utf-8"), created=self.utc_now, ) - resolved_files[local_key] = text_content.id + resolved_files[local_key] = text_media.id else: - resolved_files[local_key] = content_bytes + resolved_files[local_key] = media_bytes return resolved_files def _resolve_children(self, entity_data: dict[str, Any], lookup_map: dict[str, Any]) -> list[Any]: diff --git a/src/openedx_content/applets/components/api.py b/src/openedx_content/applets/components/api.py index c37dee707..6abce6bf8 100644 --- a/src/openedx_content/applets/components/api.py +++ b/src/openedx_content/applets/components/api.py @@ -46,8 +46,8 @@ "component_exists_by_key", "get_collection_components", "get_components", - "create_component_version_content", - "look_up_component_version_content", + "create_component_version_media", + "look_up_component_version_media", "AssetError", "get_redirect_response_for_component_asset", ] @@ -155,46 +155,46 @@ def create_component_version( def create_next_component_version( component_pk: int, /, - content_to_replace: dict[str, int | None | bytes], + media_to_replace: dict[str, int | None | bytes], created: datetime, title: str | None = None, created_by: int | None = None, *, force_version_num: int | None = None, - ignore_previous_content: bool = False, + ignore_previous_media: bool = False, ) -> ComponentVersion: """ Create a new ComponentVersion based on the most recent version. Args: component_pk (int): The primary key of the Component to version. - content_to_replace (dict): Mapping of file keys to Content IDs, - None (for deletion), or bytes (for new file content). + media_to_replace (dict): Mapping of file keys to Media IDs, + None (for deletion), or bytes (for new file media). created (datetime): The creation timestamp for the new version. title (str, optional): Title for the new version. If None, uses the previous version's title. created_by (int, optional): User ID of the creator. force_version_num (int, optional): If provided, overrides the automatic version number increment and sets this version's number explicitly. Use this if you need to restore or import a version with a specific version number, such as during data migration or when synchronizing with external systems. - ignore_previous_content (bool): If True, do not copy over content from the previous version. + ignore_previous_media (bool): If True, do not copy over media from the previous version. Returns: ComponentVersion: The newly created ComponentVersion instance. A very common pattern for making a new ComponentVersion is going to be "make it just like the last version, except changing these one or two things". - Before calling this, you should create any new contents via the contents - API or send the content bytes as part of ``content_to_replace`` values. + Before calling this, you should create any new media via the media + API or send the media bytes as part of ``media_to_replace`` values. - The ``content_to_replace`` dict is a mapping of strings representing the - local path/key for a file, to ``Content.id`` or content bytes values. Using + The ``media_to_replace`` dict is a mapping of strings representing the + local path/key for a file, to ``Media.id`` or media bytes values. Using `None` for a value in this dict means to delete that key in the next version. Make sure to wrap the function call on a atomic statement: ``with transaction.atomic():`` It is okay to mark entries for deletion that don't exist. For instance, if a - version has ``a.txt`` and ``b.txt``, sending a ``content_to_replace`` value + version has ``a.txt`` and ``b.txt``, sending a ``media_to_replace`` value of ``{"a.txt": None, "c.txt": None}`` will remove ``a.txt`` from the next version, leave ``b.txt`` alone, and will not error–even though there is no ``c.txt`` in the previous version. This is to make it a little more @@ -244,25 +244,25 @@ def create_next_component_version( component_id=component_pk, ) # First copy the new stuff over... - for key, media_pk_or_bytes in content_to_replace.items(): + for key, media_pk_or_bytes in media_to_replace.items(): # If the media_pk is None, it means we want to remove the - # content represented by our key from the next version. Otherwise, + # media represented by our key from the next version. Otherwise, # we add our key->media_pk mapping to the next version. if media_pk_or_bytes is not None: if isinstance(media_pk_or_bytes, bytes): - file_path, file_content = key, media_pk_or_bytes + file_path, file_media = key, media_pk_or_bytes media_type_str, _encoding = mimetypes.guess_type(file_path) # We use "application/octet-stream" as a generic fallback media type, per # RFC 2046: https://datatracker.ietf.org/doc/html/rfc2046 media_type_str = media_type_str or "application/octet-stream" media_type = media_api.get_or_create_media_type(media_type_str) - content = media_api.get_or_create_file_media( + media = media_api.get_or_create_file_media( component.learning_package.id, media_type.id, - data=file_content, + data=file_media, created=created, ) - media_pk = content.pk + media_pk = media.pk else: media_pk = media_pk_or_bytes ComponentVersionMedia.objects.create( @@ -271,15 +271,15 @@ def create_next_component_version( key=key, ) - if ignore_previous_content: + if ignore_previous_media: return component_version # Now copy any old associations that existed, as long as they aren't # in conflict with the new stuff or marked for deletion. - last_version_content_mapping = ComponentVersionMedia.objects \ - .filter(component_version=last_version) - for cvrc in last_version_content_mapping: - if cvrc.key not in content_to_replace: + 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: ComponentVersionMedia.objects.create( media_id=cvrc.media_id, component_version=component_version, @@ -449,17 +449,17 @@ def get_collection_components( ).order_by('pk') -def look_up_component_version_content( +def look_up_component_version_media( learning_package_key: str, component_key: str, version_num: int, key: Path, ) -> ComponentVersionMedia: """ - Look up ComponentVersionContent by human readable keys. + Look up ComponentVersionMedia by human readable keys. Can raise a django.core.exceptions.ObjectDoesNotExist error if there is no - matching ComponentVersionContent. + matching ComponentVersionMedia. This API call was only used in our proof-of-concept assets media server, and I don't know if we wantto make it a part of the public interface. @@ -472,22 +472,22 @@ def look_up_component_version_content( ) return ComponentVersionMedia.objects \ .select_related( - "content", - "content__media_type", + "media", + "media__media_type", "component_version", "component_version__component", "component_version__component__learning_package", ).get(queries) -def create_component_version_content( +def create_component_version_media( component_version_id: int, media_id: int, /, key: str, ) -> ComponentVersionMedia: """ - Add a Content to the given ComponentVersion + Add a Media to the given ComponentVersion We don't allow keys that would be absolute paths, e.g. ones that start with '/'. Storing these causes headaches with building relative paths and because @@ -499,7 +499,7 @@ def create_component_version_content( logger.warning( "Absolute paths are not supported: " f"removed leading '/' from ComponentVersion {component_version_id} " - f"content key: {repr(key)} (media_id: {media_id})" + f"media key: {repr(key)} (media_id: {media_id})" ) key = key.lstrip('/') @@ -588,7 +588,7 @@ def get_redirect_response_for_component_asset( **Asset Redirection** For performance reasons, the ``HttpResponse`` object returned by this - function does not contain the actual content data of the asset. It requires + function does not contain the actual media data of the asset. It requires an appropriately configured reverse proxy server that handles the ``X-Accel-Redirect`` header (both Caddy and Nginx support this). @@ -619,9 +619,9 @@ def _error_header(error: AssetError) -> dict[str, str]: # those headers... info_headers = _get_component_version_info_headers(component_version) - # Check: Does the ComponentVersion have the requested asset (Content)? + # Check: Does the ComponentVersion have the requested asset (Media)? try: - cv_content = component_version.componentversionmedia_set.get(key=asset_path) + cv_media = component_version.componentversionmedia_set.get(key=asset_path) except ComponentVersionMedia.DoesNotExist: logger.error(f"ComponentVersion {component_version_uuid} has no asset {asset_path}") info_headers.update( @@ -629,12 +629,12 @@ def _error_header(error: AssetError) -> dict[str, str]: ) return HttpResponseNotFound(headers=info_headers) - # Check: Does the Content have a downloadable file, instead of just inline - # text? It's easy for us to grab this content and stream it to the user + # Check: Does the Media have a downloadable file, instead of just inline + # text? It's easy for us to grab this media and stream it to the user # anyway, but we're explicitly not doing so because streaming large text # fields from the database is less scalable, and we don't want to encourage # that usage pattern. - media = cv_content.media + media = cv_media.media if not media.has_file: logger.error( f"ComponentVersion {component_version_uuid} has asset {asset_path}, " @@ -645,8 +645,8 @@ def _error_header(error: AssetError) -> dict[str, str]: ) return HttpResponseNotFound(headers=info_headers) - # At this point, we know that there is valid Content that we want to send. - # This adds Content-level headers, like the hash/etag and content type. + # At this point, we know that there is valid Media that we want to send. + # This adds Media-level headers, like the hash/etag and content type. info_headers.update(media_api.get_media_info_headers(media)) # Recompute redirect headers (reminder: this should never be cached). diff --git a/src/openedx_content/applets/components/models.py b/src/openedx_content/applets/components/models.py index 1e2ac75d1..470666076 100644 --- a/src/openedx_content/applets/components/models.py +++ b/src/openedx_content/applets/components/models.py @@ -191,11 +191,6 @@ class Meta: verbose_name = "Component" verbose_name_plural = "Components" - @property - def contents(self): - """Temp backwards compatibility shim.""" - return self.media - def __str__(self) -> str: return f"{self.component_type.namespace}:{self.component_type.name}:{self.local_key}" diff --git a/src/openedx_content/applets/media/admin.py b/src/openedx_content/applets/media/admin.py index 4c044da6d..5b268586a 100644 --- a/src/openedx_content/applets/media/admin.py +++ b/src/openedx_content/applets/media/admin.py @@ -40,32 +40,32 @@ class ContentAdmin(ReadOnlyModelAdmin): search_fields = ("hash_digest",) @admin.display(description="OS Path") - def os_path(self, content: Media): - return content.os_path() or "" + def os_path(self, media: Media): + return media.os_path() or "" - def path(self, content: Media): - return content.path if content.has_file else "" + def path(self, media: Media): + return media.path if media.has_file else "" - def text_preview(self, content: Media): - if not content.text: + def text_preview(self, media: Media): + if not media.text: return "" return format_html( '
\n{}\n
', - content.text, + media.text, ) - def image_preview(self, content: Media): + def image_preview(self, media: Media): """ Return HTML for an image, if that is the underlying Content. Otherwise, just return a blank string. """ - if content.media_type.type != "image": + if media.media_type.type != "image": return "" - data = content.read_file().read() + data = media.read_file().read() return format_html( '', - content.mime_type, + media.mime_type, base64.encodebytes(data).decode('utf8'), ) diff --git a/src/openedx_content/applets/media/models.py b/src/openedx_content/applets/media/models.py index bce9e5a3e..755405f77 100644 --- a/src/openedx_content/applets/media/models.py +++ b/src/openedx_content/applets/media/models.py @@ -311,6 +311,9 @@ def path(self): root. This file may not exist because has_file=False, or because we haven't written the file yet (this is the method we call when trying to figure out where the file *should* go). + + For historical reasons (and backwards compatibility), the prefix for + this path is "content/" and not "media/". """ return f"content/{self.learning_package.uuid}/{self.hash_digest}" @@ -414,4 +417,4 @@ class Meta: ), ] verbose_name = "Media" - verbose_name_plural = "Medias" + verbose_name_plural = "Media" 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 86c490fce..2d34c603c 100644 --- a/src/openedx_content/management/commands/add_assets_to_component.py +++ b/src/openedx_content/management/commands/add_assets_to_component.py @@ -74,7 +74,7 @@ def handle(self, *args, **options): next_version = create_next_component_version( component.pk, - content_to_replace=local_keys_to_content_bytes, + media_to_replace=local_keys_to_content_bytes, created=created, ) diff --git a/src/openedx_core/__init__.py b/src/openedx_core/__init__.py index 512404031..8cd9fef77 100644 --- a/src/openedx_core/__init__.py +++ b/src/openedx_core/__init__.py @@ -6,4 +6,4 @@ """ # The version for the entire repository -__version__ = "0.34.2" +__version__ = "0.35.0" diff --git a/tests/openedx_content/applets/backup_restore/test_backup.py b/tests/openedx_content/applets/backup_restore/test_backup.py index 30cfb7726..baa4b8d23 100644 --- a/tests/openedx_content/applets/backup_restore/test_backup.py +++ b/tests/openedx_content/applets/backup_restore/test_backup.py @@ -96,7 +96,7 @@ def setUpTestData(cls): new_problem_version = api.create_next_component_version( cls.published_component.pk, title="My published problem draft v2", - content_to_replace={}, + media_to_replace={}, created=cls.now, ) @@ -106,7 +106,7 @@ def setUpTestData(cls): text="This is some data", created=cls.now, ) - api.create_component_version_content( + api.create_component_version_media( new_problem_version.pk, new_txt_content.pk, key="hello.txt", @@ -125,7 +125,7 @@ def setUpTestData(cls): new_html_version = api.create_next_component_version( cls.draft_component.pk, title="My draft html v2", - content_to_replace={}, + media_to_replace={}, created=cls.now, ) @@ -135,7 +135,7 @@ def setUpTestData(cls): data=b"hello world!", created=cls.now, ) - api.create_component_version_content( + api.create_component_version_media( new_html_version.pk, cls.html_asset_content.id, key="static/other/subdirectory/hello.html", diff --git a/tests/openedx_content/applets/components/test_api.py b/tests/openedx_content/applets/components/test_api.py index c207107a9..a33f8e12e 100644 --- a/tests/openedx_content/applets/components/test_api.py +++ b/tests/openedx_content/applets/components/test_api.py @@ -406,7 +406,7 @@ def test_add(self): text="This is some data", created=self.now, ) - components_api.create_component_version_content( + components_api.create_component_version_media( new_version.pk, new_content.pk, key="my/path/to/hello.txt", @@ -422,7 +422,7 @@ def test_add(self): # Write the same content again, but to an absolute path (should auto- # strip) the leading '/'s. - components_api.create_component_version_content( + components_api.create_component_version_media( new_version.pk, new_content.pk, key="//nested/path/hello.txt", @@ -441,7 +441,7 @@ def test_bytes_content(self): version_1 = components_api.create_next_component_version( self.problem.pk, title="Problem Version 1", - content_to_replace={ + media_to_replace={ "raw.txt": bytes_content, "no_ext": bytes_content, }, @@ -483,7 +483,7 @@ def test_multiple_versions(self): version_1 = components_api.create_next_component_version( self.problem.pk, title="Problem Version 1", - content_to_replace={ + media_to_replace={ "hello.txt": hello_content.pk, "goodbye.txt": goodbye_content.pk, }, @@ -509,7 +509,7 @@ def test_multiple_versions(self): version_2 = components_api.create_next_component_version( self.problem.pk, title="Problem Version 2", - content_to_replace={ + media_to_replace={ "hello.txt": blank_content.pk, "blank.txt": blank_content.pk, }, @@ -538,7 +538,7 @@ def test_multiple_versions(self): version_3 = components_api.create_next_component_version( self.problem.pk, title="Problem Version 3", - content_to_replace={ + media_to_replace={ "hello.txt": hello_content.pk, "blank.txt": None, "goodbye.txt": None, @@ -559,7 +559,7 @@ def test_create_next_version_forcing_num_version(self): version_1 = components_api.create_next_component_version( self.problem.pk, title="Problem Version 1", - content_to_replace={}, + media_to_replace={}, created=self.now, force_version_num=5, ) @@ -579,19 +579,19 @@ def test_create_multiple_next_versions_and_diff_content(self): data=b"print('hello world!')", created=self.now, ) - content_to_replace_for_published = { + media_to_replace_for_published = { 'static/profile.webp': python_source_asset.pk, 'static/background.webp': python_source_asset.pk, } - content_to_replace_for_draft = { + media_to_replace_for_draft = { 'static/profile.webp': python_source_asset.pk, 'static/new_file.webp': python_source_asset.pk, } version_1_published = components_api.create_next_component_version( self.problem.pk, title="Problem Version 1", - content_to_replace=content_to_replace_for_published, + media_to_replace=media_to_replace_for_published, created=self.now, ) assert version_1_published.version_num == 1 @@ -604,9 +604,9 @@ def test_create_multiple_next_versions_and_diff_content(self): version_2_draft = components_api.create_next_component_version( self.problem.pk, title="Problem Version 2", - content_to_replace=content_to_replace_for_draft, + media_to_replace=media_to_replace_for_draft, created=self.now, - ignore_previous_content=True, + ignore_previous_media=True, ) assert version_2_draft.version_num == 2 assert version_2_draft.media.count() == 2 diff --git a/tests/openedx_content/applets/components/test_assets.py b/tests/openedx_content/applets/components/test_assets.py index 297dbdd0f..511bfc91e 100644 --- a/tests/openedx_content/applets/components/test_assets.py +++ b/tests/openedx_content/applets/components/test_assets.py @@ -72,7 +72,7 @@ def setUpTestData(cls) -> None: text="(pretend problem OLX is here)", created=cls.now, ) - components_api.create_component_version_content( + components_api.create_component_version_media( cls.component_version.pk, cls.problem_content.id, key="block.xml", @@ -86,7 +86,7 @@ def setUpTestData(cls) -> None: data=b"print('hello world!')", created=cls.now, ) - components_api.create_component_version_content( + components_api.create_component_version_media( cls.component_version.pk, cls.python_source_asset.id, key="src/grader.py", @@ -99,7 +99,7 @@ def setUpTestData(cls) -> None: data=b"hello world!", created=cls.now, ) - components_api.create_component_version_content( + components_api.create_component_version_media( cls.component_version.pk, cls.html_asset_content.id, key="static/hello.html", diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index aca4fa58c..588d9d839 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -74,7 +74,7 @@ def modify_component( """ return content_api.create_next_component_version( component.pk, - content_to_replace={}, + media_to_replace={}, title=title, created=timestamp or self.now, created_by=None, From 44923591ed9766433e86ff485db2f8705f18a6da Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 20 Feb 2026 14:07:22 -0500 Subject: [PATCH 07/12] temp: more renaming --- .../management/commands/load_components.py | 4 +- .../applets/backup_restore/test_backup.py | 10 ++-- .../applets/backup_restore/test_restore.py | 12 ++--- .../applets/components/test_api.py | 52 +++++++++---------- .../applets/components/test_assets.py | 16 +++--- .../applets/media/test_file_storage.py | 12 ++--- 6 files changed, 53 insertions(+), 53 deletions(-) diff --git a/olx_importer/management/commands/load_components.py b/olx_importer/management/commands/load_components.py index 1bbd72da2..175175040 100644 --- a/olx_importer/management/commands/load_components.py +++ b/olx_importer/management/commands/load_components.py @@ -114,7 +114,7 @@ def create_content(self, static_local_path, now, component_version): logger.warning(f' Static reference not found: "{real_path}"') return # Might as well bail if we can't find the file. - content = content_api.get_or_create_file_media( + media = content_api.get_or_create_file_media( self.learning_package.id, data=data_bytes, mime_type=mime_type, @@ -122,7 +122,7 @@ def create_content(self, static_local_path, now, component_version): ) content_api.create_component_version_media( component_version, - content.id, + media.id, key=key, ) diff --git a/tests/openedx_content/applets/backup_restore/test_backup.py b/tests/openedx_content/applets/backup_restore/test_backup.py index baa4b8d23..a63820b9e 100644 --- a/tests/openedx_content/applets/backup_restore/test_backup.py +++ b/tests/openedx_content/applets/backup_restore/test_backup.py @@ -32,7 +32,7 @@ class LpDumpCommandTestCase(TestCase): published_component: Component published_component2: Component draft_component: Component - html_asset_content: Media + html_asset_media: Media collection: Collection @classmethod @@ -100,7 +100,7 @@ def setUpTestData(cls): created=cls.now, ) - new_txt_content = api.get_or_create_text_media( + new_txt_media = api.get_or_create_text_media( cls.learning_package.pk, text_media_type.id, text="This is some data", @@ -108,7 +108,7 @@ def setUpTestData(cls): ) api.create_component_version_media( new_problem_version.pk, - new_txt_content.pk, + new_txt_media.pk, key="hello.txt", ) @@ -129,7 +129,7 @@ def setUpTestData(cls): created=cls.now, ) - cls.html_asset_content = api.get_or_create_file_media( + cls.html_asset_media = api.get_or_create_file_media( cls.learning_package.id, html_media_type.id, data=b"hello world!", @@ -137,7 +137,7 @@ def setUpTestData(cls): ) api.create_component_version_media( new_html_version.pk, - cls.html_asset_content.id, + cls.html_asset_media.id, key="static/other/subdirectory/hello.html", ) diff --git a/tests/openedx_content/applets/backup_restore/test_restore.py b/tests/openedx_content/applets/backup_restore/test_restore.py index 88419cd29..cd3ac83cc 100644 --- a/tests/openedx_content/applets/backup_restore/test_restore.py +++ b/tests/openedx_content/applets/backup_restore/test_restore.py @@ -117,12 +117,12 @@ def verify_components(self, lp): assert draft_version.created_by.username == "lp_user" assert published_version is None # Get the content associated with this component - contents = draft_version.componentversion.media.all() - content = contents.first() if contents.exists() else None - assert content is not None - assert " None: ) # ProblemBlock content that is stored as text Content, not a file. - cls.problem_content = media_api.get_or_create_text_media( + cls.problem_media = media_api.get_or_create_text_media( cls.learning_package.id, cls.problem_block_media_type.id, text="(pretend problem OLX is here)", @@ -74,7 +74,7 @@ def setUpTestData(cls) -> None: ) components_api.create_component_version_media( cls.component_version.pk, - cls.problem_content.id, + cls.problem_media.id, key="block.xml", ) @@ -93,7 +93,7 @@ def setUpTestData(cls) -> None: ) # An HTML file that is student downloadable - cls.html_asset_content = media_api.get_or_create_file_media( + cls.html_asset_media = media_api.get_or_create_file_media( cls.learning_package.id, cls.html_media_type.id, data=b"hello world!", @@ -101,7 +101,7 @@ def setUpTestData(cls) -> None: ) components_api.create_component_version_media( cls.component_version.pk, - cls.html_asset_content.id, + cls.html_asset_media.id, key="static/hello.html", ) @@ -159,9 +159,9 @@ def _assert_html_content_headers(self, response): """Assert expected HttpResponse headers for a downloadable HTML file.""" self._assert_has_component_version_headers(response.headers) assert response.status_code == 200 - assert response.headers["Etag"] == self.html_asset_content.hash_digest + assert response.headers["Etag"] == self.html_asset_media.hash_digest assert response.headers["Content-Type"] == "text/html" - assert response.headers["X-Accel-Redirect"] == self.html_asset_content.path + assert response.headers["X-Accel-Redirect"] == self.html_asset_media.path assert "X-Open-edX-Error" not in response.headers def test_public_asset_response(self): diff --git a/tests/openedx_content/applets/media/test_file_storage.py b/tests/openedx_content/applets/media/test_file_storage.py index 88b71286f..7cf6ea664 100644 --- a/tests/openedx_content/applets/media/test_file_storage.py +++ b/tests/openedx_content/applets/media/test_file_storage.py @@ -33,7 +33,7 @@ def setUp(self) -> None: title="Content File Storage Test Case Learning Package", ) self.html_media_type = media_api.get_or_create_media_type("text/html") - self.html_content = media_api.get_or_create_file_media( + self.html_media = media_api.get_or_create_file_media( learning_package.id, self.html_media_type.id, data=b"hello world!", @@ -49,15 +49,15 @@ def test_file_path(self): breaking backwards compatibility for everyone. Please be very careful if you're updating this test. """ - content = self.html_content - assert content.path == f"content/{content.learning_package.uuid}/{content.hash_digest}" + media = self.html_media + assert media.path == f"content/{media.learning_package.uuid}/{media.hash_digest}" storage_root = settings.OPENEDX_LEARNING['MEDIA']['OPTIONS']['location'] - assert content.os_path() == f"{storage_root}/{content.path}" + assert media.os_path() == f"{storage_root}/{media.path}" def test_read(self): """Make sure we can read the file data back.""" - assert b"hello world!" == self.html_content.read_file().read() + assert b"hello world!" == self.html_media.read_file().read() @override_settings() def test_misconfiguration(self): @@ -75,4 +75,4 @@ def test_misconfiguration(self): get_storage.cache_clear() del settings.OPENEDX_LEARNING with self.assertRaises(ImproperlyConfigured): - self.html_content.read_file() + self.html_media.read_file() From 2d28545f32a9623be841af6fe3fe1041641d6351 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 20 Feb 2026 16:18:21 -0500 Subject: [PATCH 08/12] temp: cvc -> cvm --- .../applets/components/admin.py | 18 +++++++++--------- .../applets/components/models.py | 2 +- .../commands/add_assets_to_component.py | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/openedx_content/applets/components/admin.py b/src/openedx_content/applets/components/admin.py index 824e62ad7..3899fa050 100644 --- a/src/openedx_content/applets/components/admin.py +++ b/src/openedx_content/applets/components/admin.py @@ -81,15 +81,15 @@ def get_queryset(self, request): ] extra = 0 - def has_file(self, cvc_obj): - return cvc_obj.media.has_file + def has_file(self, cvm_obj): + return cvm_obj.media.has_file - def rendered_data(self, cvc_obj): - return media_preview(cvc_obj) + def rendered_data(self, cvm_obj): + return media_preview(cvm_obj) @admin.display(description="Size") - def format_size(self, cvc_obj): - return filesizeformat(cvc_obj.media.size) + def format_size(self, cvm_obj): + return filesizeformat(cvm_obj.media.size) @admin.register(ComponentVersion) @@ -134,11 +134,11 @@ def format_text_for_admin_display(text: str) -> SafeText: ) -def media_preview(cvc_obj: ComponentVersionMedia) -> SafeText: +def media_preview(cvm_obj: ComponentVersionMedia) -> SafeText: """ - Get the HTML to display a preview of the given ComponentVersionContent + Get the HTML to display a preview of the given ComponentVersionMedia """ - media_obj = cvc_obj.media + media_obj = cvm_obj.media if media_obj.media_type.type == "image": # This base64 encoding looks really goofy and is bad for performance, diff --git a/src/openedx_content/applets/components/models.py b/src/openedx_content/applets/components/models.py index 470666076..e34a39776 100644 --- a/src/openedx_content/applets/components/models.py +++ b/src/openedx_content/applets/components/models.py @@ -11,7 +11,7 @@ maps 1:1 to PublishableEntityVersion. Multiple pieces of Content may be associated with a ComponentVersion, through -the ComponentVersionContent model. ComponentVersionContent allows to specify a +the ComponentVersionMedia model. ComponentVersionMedia allows to specify a ComponentVersion-local identifier. We're using this like a file path by convention, but it's possible we might want to have special identifiers later. """ 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 2d34c603c..7078c7f73 100644 --- a/src/openedx_content/management/commands/add_assets_to_component.py +++ b/src/openedx_content/management/commands/add_assets_to_component.py @@ -82,5 +82,5 @@ def handle(self, *args, **options): f"Created v{next_version.version_num} of " f"{next_version.component.key} ({next_version.uuid}):" ) - for cvc in next_version.componentversionmedia_set.all(): - self.stdout.write(f"- {cvc.key} ({cvc.uuid})") + for cvm in next_version.componentversionmedia_set.all(): + self.stdout.write(f"- {cvm.key} ({cvm.uuid})") From f7b4ae4eecf7e3fc83c1e66ab9c2f0bb8bf4fa78 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 20 Feb 2026 16:23:37 -0500 Subject: [PATCH 09/12] temp: fix importlinter entry --- .importlinter | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.importlinter b/.importlinter index 182d723bc..1afc4c3ec 100644 --- a/.importlinter +++ b/.importlinter @@ -50,9 +50,9 @@ layers= # has no child elements. openedx_content.applets.components - # The "contents" applet stores the simplest pieces of binary and text data, + # The "media" applet stores the simplest pieces of binary and text data, # without versioning information. These belong to a single Learning Package. - openedx_content.applets.contents + openedx_content.applets.media # The "collections" applet stores arbitrary groupings of PublishableEntities. # Its only dependency should be the publishing app. From fc6188aeec22088342c7c9d67e3bdee0f987fa24 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 20 Feb 2026 19:11:11 -0500 Subject: [PATCH 10/12] temp: lint error fixing --- src/openedx_content/applets/backup_restore/zipper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 0a695e7c6..d5584e842 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -373,11 +373,11 @@ def create_zip(self, path: str) -> None: component_version: ComponentVersion = version.componentversion # Get media data associated with this version - media: QuerySet[ + prefetched_media: QuerySet[ ComponentVersionMedia ] = component_version.prefetched_media # type: ignore[attr-defined] - for component_version_media in media: + for component_version_media in prefetched_media: media: Media = component_version_media.media # Important: The component_version_media.key contains implicitly From 48f08370d847fb09aefb84e2aa8196d08063e614 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Sat, 21 Feb 2026 01:54:19 -0500 Subject: [PATCH 11/12] temp: another small lint fix --- src/openedx_content/applets/components/api.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/openedx_content/applets/components/api.py b/src/openedx_content/applets/components/api.py index 6abce6bf8..c106e0267 100644 --- a/src/openedx_content/applets/components/api.py +++ b/src/openedx_content/applets/components/api.py @@ -471,13 +471,13 @@ def look_up_component_version_media( & Q(key=key) ) return ComponentVersionMedia.objects \ - .select_related( - "media", - "media__media_type", - "component_version", - "component_version__component", - "component_version__component__learning_package", - ).get(queries) + .select_related( + "media", + "media__media_type", + "component_version", + "component_version__component", + "component_version__component__learning_package", + ).get(queries) def create_component_version_media( From 7a4c8d2406ff1465ed8d8f3bcaced83a84ed1ce9 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Sun, 22 Feb 2026 11:08:35 -0700 Subject: [PATCH 12/12] temp: lint fix --- src/openedx_content/applets/components/api.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/openedx_content/applets/components/api.py b/src/openedx_content/applets/components/api.py index c106e0267..46de5356e 100644 --- a/src/openedx_content/applets/components/api.py +++ b/src/openedx_content/applets/components/api.py @@ -471,13 +471,13 @@ def look_up_component_version_media( & Q(key=key) ) return ComponentVersionMedia.objects \ - .select_related( - "media", - "media__media_type", - "component_version", - "component_version__component", - "component_version__component__learning_package", - ).get(queries) + .select_related( + "media", + "media__media_type", + "component_version", + "component_version__component", + "component_version__component__learning_package", + ).get(queries) def create_component_version_media(