Skip to content

Commit 81bdecf

Browse files
committed
feat!: remove create_component_version_media
Media associations must now be specified at the time when new ComponentVersions are created: * create_component_version * create_component_and_version * create_next_component_version ComponentVersions are intended to be immutable, and having create_component_version_media meant that we were encouraging a pattern where people would create a component version first, and then make modifications to it after the fact. This bumps the version to 0.46.0.
1 parent 9cc97b9 commit 81bdecf

7 files changed

Lines changed: 224 additions & 143 deletions

File tree

src/openedx_content/applets/components/api.py

Lines changed: 100 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import mimetypes
1616
from datetime import datetime
1717
from enum import StrEnum, auto
18+
from functools import cache
1819
from logging import getLogger
1920
from pathlib import Path
2021
from uuid import UUID
@@ -24,6 +25,7 @@
2425
from django.http.response import HttpResponse, HttpResponseNotFound
2526

2627
from ..media import api as media_api
28+
from ..media.models import Media
2729
from ..publishing import api as publishing_api
2830
from .models import Component, ComponentType, ComponentVersion, ComponentVersionMedia, LearningPackage
2931

@@ -45,7 +47,6 @@
4547
"component_exists_by_code",
4648
"get_collection_components",
4749
"get_components",
48-
"create_component_version_media",
4950
"look_up_component_version_media",
5051
"AssetError",
5152
"get_redirect_response_for_component_asset",
@@ -115,6 +116,8 @@ def create_component_version(
115116
title: str,
116117
created: datetime,
117118
created_by: int | None,
119+
*,
120+
media: dict[str, Media.ID | Media | bytes] | None = None,
118121
) -> ComponentVersion:
119122
"""
120123
Create a new ComponentVersion
@@ -131,13 +134,16 @@ def create_component_version(
131134
publishable_entity_version=publishable_entity_version,
132135
component_id=component_id,
133136
)
137+
if media:
138+
_set_component_version_media(component_version, media, created=created)
139+
134140
return component_version
135141

136142

137143
def create_next_component_version(
138144
component_id: Component.ID,
139145
/,
140-
media_to_replace: dict[str, int | None | bytes],
146+
media_to_replace: dict[str, Media.ID | Media | bytes | None],
141147
created: datetime,
142148
title: str | None = None,
143149
created_by: int | None = None,
@@ -191,8 +197,6 @@ def create_next_component_version(
191197
Why not use create_component_version?
192198
The main reason is that we want to reuse the logic to create a static file component from a dictionary.
193199
194-
TODO: Have to add learning_downloadable info to this when it comes time to
195-
support static asset download.
196200
"""
197201
# This needs to grab the highest version_num for this Publishable Entity.
198202
# This will often be the Draft version, but not always. For instance, if
@@ -225,50 +229,31 @@ def create_next_component_version(
225229
publishable_entity_version=publishable_entity_version,
226230
component_id=component_id,
227231
)
228-
# First copy the new stuff over...
229-
for key, media_pk_or_bytes in media_to_replace.items():
230-
# If the media_pk is None, it means we want to remove the
231-
# media represented by our key from the next version. Otherwise,
232-
# we add our key->media_pk mapping to the next version.
233-
if media_pk_or_bytes is not None:
234-
if isinstance(media_pk_or_bytes, bytes):
235-
file_path, file_media = key, media_pk_or_bytes
236-
media_type_str, _encoding = mimetypes.guess_type(file_path)
237-
# We use "application/octet-stream" as a generic fallback media type, per
238-
# RFC 2046: https://datatracker.ietf.org/doc/html/rfc2046
239-
media_type_str = media_type_str or "application/octet-stream"
240-
media_type = media_api.get_or_create_media_type(media_type_str)
241-
media = media_api.get_or_create_file_media(
242-
component.learning_package.id,
243-
media_type.id,
244-
data=file_media,
245-
created=created,
246-
)
247-
media_pk = media.pk
248-
else:
249-
media_pk = media_pk_or_bytes
250-
ComponentVersionMedia.objects.create(
251-
media_id=media_pk,
252-
component_version=component_version,
253-
path=key,
254-
)
255232

256-
if ignore_previous_media:
257-
return component_version
258-
259-
# Now copy any old associations that existed, as long as they aren't
260-
# in conflict with the new stuff or marked for deletion.
261-
last_version_media_mapping = ComponentVersionMedia.objects \
262-
.filter(component_version=last_version)
263-
for cvrc in last_version_media_mapping:
264-
if cvrc.path not in media_to_replace:
265-
ComponentVersionMedia.objects.create(
266-
media_id=cvrc.media_id,
267-
component_version=component_version,
268-
path=cvrc.path,
233+
if ignore_previous_media or last_version is None:
234+
paths_to_media = {
235+
path: media
236+
for path, media in media_to_replace.items()
237+
if media is not None # Ignore deletion entries in this case.
238+
}
239+
else:
240+
# Most of the time, we're adding our media changes as a delta on top
241+
# of the last version's media.
242+
previous_media = {
243+
cvm.path: cvm.media_id
244+
for cvm in ComponentVersionMedia.objects.filter(
245+
component_version=last_version
269246
)
247+
}
248+
paths_to_media = {
249+
path: media
250+
for path, media in (previous_media | media_to_replace).items()
251+
if media is not None # "media is None" means "delete this"
252+
}
270253

271-
return component_version
254+
_set_component_version_media(component_version, paths_to_media, created)
255+
256+
return component_version
272257

273258

274259
def create_component_and_version( # pylint: disable=too-many-positional-arguments
@@ -281,6 +266,7 @@ def create_component_and_version( # pylint: disable=too-many-positional-argumen
281266
created_by: int | None = None,
282267
*,
283268
can_stand_alone: bool = True,
269+
media: dict[str, Media.ID | Media | bytes] | None = None,
284270
) -> tuple[Component, ComponentVersion]:
285271
"""
286272
Create a Component and associated ComponentVersion atomically.
@@ -300,8 +286,76 @@ def create_component_and_version( # pylint: disable=too-many-positional-argumen
300286
title=title,
301287
created=created,
302288
created_by=created_by,
289+
media=media or {},
303290
)
304-
return (component, component_version)
291+
292+
return (component, component_version)
293+
294+
295+
def _set_component_version_media(
296+
version: ComponentVersion,
297+
paths_to_media_values: dict[str, Media.ID | Media | bytes],
298+
created: datetime,
299+
):
300+
"""
301+
Internal helper to set the Media for this ComponentVersion.
302+
303+
Only call this when we're first initializing a ComponentVersion.
304+
305+
Media can be specified as ``bytes`` for testing convenience, but you will
306+
almost always want to create a Media object first in actual app code,
307+
because that will give you better control over the MIME type and storage
308+
specifics (file vs. database).
309+
310+
Note that unlike create_next_component_version(), we don't accept `None` as
311+
a media value here. This function does not carry over any Media associations
312+
from past ComponentVersions, so our "None means Delete" convention doesn't
313+
apply here.
314+
"""
315+
@cache # want to avoid repeated lookups, e.g. a component with ten images
316+
def cached_media_type(media_type_str):
317+
return media_api.get_or_create_media_type(media_type_str)
318+
319+
# We allow a range of values to be in paths_to_media_values, but we want to
320+
# normalize to media_ids for our bulk insert later.
321+
paths_to_media_ids: dict[str, Media.ID] = {}
322+
323+
for path, media_value in paths_to_media_values.items():
324+
match media_value:
325+
case int(): # MediaID
326+
media_id = media_value
327+
case Media():
328+
media_id = media_value.id
329+
case bytes():
330+
media_type_str, _encoding = mimetypes.guess_type(path)
331+
# We use "application/octet-stream" as a generic fallback media type, per
332+
# RFC 2046: https://datatracker.ietf.org/doc/html/rfc2046
333+
media_type_str = media_type_str or "application/octet-stream"
334+
media_type = cached_media_type(media_type_str)
335+
media = media_api.get_or_create_file_media(
336+
version.component.learning_package.id,
337+
media_type.id,
338+
data=media_value,
339+
created=created,
340+
)
341+
media_id = media.id
342+
case _:
343+
raise ValueError(f"Invalid object for paths_to_media Media: {media_value!r}")
344+
345+
# Don't allow whitespace, absolute paths, or Windows-style paths
346+
normalized_path = path.strip().replace('\\', '/').lstrip('/')
347+
paths_to_media_ids[normalized_path] = media_id
348+
349+
ComponentVersionMedia.objects.bulk_create(
350+
[
351+
ComponentVersionMedia(
352+
component_version=version,
353+
path=normalized_path,
354+
media_id=media_id,
355+
)
356+
for normalized_path, media_id in paths_to_media_ids.items()
357+
]
358+
)
305359

306360

307361
def get_component(component_id: Component.ID, /) -> Component:
@@ -462,37 +516,6 @@ def look_up_component_version_media(
462516
).get(queries)
463517

464518

465-
def create_component_version_media(
466-
component_version_id: int,
467-
media_id: int,
468-
/,
469-
path: str,
470-
) -> ComponentVersionMedia:
471-
"""
472-
Add a Media to the given ComponentVersion
473-
474-
We don't allow paths that would be absolute, e.g. ones that start with
475-
'/'. Storing these causes headaches with building relative paths and because
476-
of mismatches with things that expect a leading slash and those that don't.
477-
So for safety and consistency, we strip off leading slashes and emit a
478-
warning when we do.
479-
"""
480-
if path.startswith('/'):
481-
logger.warning(
482-
"Absolute paths are not supported: "
483-
f"removed leading '/' from ComponentVersion {component_version_id} "
484-
f"media path: {repr(path)} (media_id: {media_id})"
485-
)
486-
path = path.lstrip('/')
487-
488-
cvrc, _created = ComponentVersionMedia.objects.get_or_create(
489-
component_version_id=component_version_id,
490-
media_id=media_id,
491-
path=path,
492-
)
493-
return cvrc
494-
495-
496519
class AssetError(StrEnum):
497520
"""Error codes related to fetching ComponentVersion assets."""
498521
ASSET_PATH_NOT_FOUND_FOR_COMPONENT_VERSION = auto()

src/openedx_content/applets/media/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from functools import cache, cached_property
99
from logging import getLogger
10+
from typing import NewType
1011

1112
from django.conf import settings
1213
from django.core.exceptions import ImproperlyConfigured, ValidationError
@@ -18,6 +19,7 @@
1819

1920
from openedx_django_lib.fields import (
2021
MultiCollationTextField,
22+
TypedBigAutoField,
2123
case_insensitive_char_field,
2224
hash_field,
2325
manual_date_time_field,
@@ -240,6 +242,16 @@ class Media(models.Model):
240242
# could be as much as 200K of data if we had nothing but emojis.
241243
MAX_TEXT_LENGTH = 50_000
242244

245+
# Custom type for our primary key, to make it more type-safe when using in
246+
# API calls.
247+
MediaID = NewType("MediaID", int)
248+
type ID = MediaID
249+
250+
class IDField(TypedBigAutoField[ID]): # Boilerplate for fully-typed ID field.
251+
pass
252+
253+
id = IDField(primary_key=True)
254+
243255
objects: models.Manager[Media] = WithRelationsManager('media_type')
244256

245257
learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 5.2.12 on 2026-04-28 14:40
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
"""
8+
Media.id was previously auto-created by Django (hence auto_created=True,
9+
verbose_name='ID' in the original migration). It is now explicitly declared
10+
on the model. The database column is unchanged (still BIGINT AUTO_INCREMENT
11+
PRIMARY KEY), so database_operations is empty.
12+
"""
13+
dependencies = [
14+
('openedx_content', '0013_unicode_container_component_codes'),
15+
]
16+
17+
operations = [
18+
migrations.SeparateDatabaseAndState(
19+
state_operations=[
20+
migrations.AlterField(
21+
model_name='media',
22+
name='id',
23+
field=models.BigAutoField(primary_key=True, serialize=False),
24+
),
25+
],
26+
database_operations=[],
27+
)
28+
]

src/openedx_core/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
"""
77

88
# The version for the entire repository
9-
__version__ = "0.45.0"
9+
__version__ = "0.46.0"

tests/openedx_content/applets/backup_restore/test_backup.py

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -93,23 +93,19 @@ def setUpTestData(cls):
9393
published_at=cls.now,
9494
)
9595

96-
new_problem_version = api.create_next_component_version(
97-
cls.published_component.id,
98-
title="My published problem draft v2",
99-
media_to_replace={},
100-
created=cls.now,
101-
)
102-
10396
new_txt_media = api.get_or_create_text_media(
10497
cls.learning_package.id,
10598
text_media_type.id,
10699
text="This is some data",
107100
created=cls.now,
108101
)
109-
api.create_component_version_media(
110-
new_problem_version.pk,
111-
new_txt_media.pk,
112-
path="hello.txt",
102+
api.create_next_component_version(
103+
cls.published_component.id,
104+
title="My published problem draft v2",
105+
media_to_replace={
106+
'hello.txt': new_txt_media
107+
},
108+
created=cls.now,
113109
)
114110

115111
# Create a Draft component, one in each learning package
@@ -122,23 +118,19 @@ def setUpTestData(cls):
122118
created_by=cls.user.id,
123119
)
124120

125-
new_html_version = api.create_next_component_version(
126-
cls.draft_component.id,
127-
title="My draft html v2",
128-
media_to_replace={},
129-
created=cls.now,
130-
)
131-
132121
cls.html_asset_media = api.get_or_create_file_media(
133122
cls.learning_package.id,
134123
html_media_type.id,
135124
data=b"<html>hello world!</html>",
136125
created=cls.now,
137126
)
138-
api.create_component_version_media(
139-
new_html_version.pk,
140-
cls.html_asset_media.id,
141-
path="static/other/subdirectory/hello.html",
127+
api.create_next_component_version(
128+
cls.draft_component.id,
129+
title="My draft html v2",
130+
media_to_replace={
131+
"static/other/subdirectory/hello.html": cls.html_asset_media
132+
},
133+
created=cls.now,
142134
)
143135

144136
components = api.get_publishable_entities(cls.learning_package)

0 commit comments

Comments
 (0)