1515import mimetypes
1616from datetime import datetime
1717from enum import StrEnum , auto
18+ from functools import cache
1819from logging import getLogger
1920from pathlib import Path
2021from uuid import UUID
2425from django .http .response import HttpResponse , HttpResponseNotFound
2526
2627from ..media import api as media_api
28+ from ..media .models import Media
2729from ..publishing import api as publishing_api
2830from .models import Component , ComponentType , ComponentVersion , ComponentVersionMedia , LearningPackage
2931
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
137143def 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
274259def 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
307361def 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-
496519class AssetError (StrEnum ):
497520 """Error codes related to fetching ComponentVersion assets."""
498521 ASSET_PATH_NOT_FOUND_FOR_COMPONENT_VERSION = auto ()
0 commit comments