openedx-learning 0.11.5__tar.gz → 0.12.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. {openedx_learning-0.11.5/openedx_learning.egg-info → openedx_learning-0.12.0}/PKG-INFO +2 -2
  2. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/__init__.py +1 -1
  3. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/components/api.py +200 -2
  4. openedx_learning-0.12.0/openedx_learning/apps/authoring/components/management/commands/add_assets_to_component.py +111 -0
  5. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/contents/api.py +69 -0
  6. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/contents/models.py +20 -4
  7. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/publishing/model_mixins.py +10 -0
  8. {openedx_learning-0.11.5 → openedx_learning-0.12.0/openedx_learning.egg-info}/PKG-INFO +2 -2
  9. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning.egg-info/SOURCES.txt +3 -0
  10. openedx_learning-0.12.0/openedx_tagging/core/tagging/rest_api/v1/__init__.py +0 -0
  11. openedx_learning-0.12.0/openedx_tagging/py.typed +0 -0
  12. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/CHANGELOG.rst +0 -0
  13. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/LICENSE.txt +0 -0
  14. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/MANIFEST.in +0 -0
  15. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/README.rst +0 -0
  16. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/api/__init__.py +0 -0
  17. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/api/authoring.py +0 -0
  18. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/api/authoring_models.py +0 -0
  19. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/__init__.py +0 -0
  20. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/__init__.py +0 -0
  21. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/collections/__init__.py +0 -0
  22. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/collections/api.py +0 -0
  23. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/collections/apps.py +0 -0
  24. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/collections/migrations/0001_initial.py +0 -0
  25. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/collections/migrations/0002_remove_collection_name_collection_created_by_and_more.py +0 -0
  26. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/collections/migrations/0003_collection_entities.py +0 -0
  27. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/collections/migrations/0004_collection_key.py +0 -0
  28. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/collections/migrations/__init__.py +0 -0
  29. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/collections/models.py +0 -0
  30. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/components/__init__.py +0 -0
  31. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/components/admin.py +0 -0
  32. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/components/apps.py +0 -0
  33. {openedx_learning-0.11.5/openedx_learning/apps/authoring/components/migrations → openedx_learning-0.12.0/openedx_learning/apps/authoring/components/management}/__init__.py +0 -0
  34. {openedx_learning-0.11.5/openedx_learning/apps/authoring/contents → openedx_learning-0.12.0/openedx_learning/apps/authoring/components/management/commands}/__init__.py +0 -0
  35. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/components/migrations/0001_initial.py +0 -0
  36. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/components/migrations/0002_alter_componentversioncontent_key.py +0 -0
  37. {openedx_learning-0.11.5/openedx_learning/apps/authoring/contents → openedx_learning-0.12.0/openedx_learning/apps/authoring/components}/migrations/__init__.py +0 -0
  38. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/components/models.py +0 -0
  39. {openedx_learning-0.11.5/openedx_learning/apps/authoring/publishing → openedx_learning-0.12.0/openedx_learning/apps/authoring/contents}/__init__.py +0 -0
  40. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/contents/admin.py +0 -0
  41. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/contents/apps.py +0 -0
  42. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/contents/migrations/0001_initial.py +0 -0
  43. {openedx_learning-0.11.5/openedx_learning/apps/authoring/publishing → openedx_learning-0.12.0/openedx_learning/apps/authoring/contents}/migrations/__init__.py +0 -0
  44. {openedx_learning-0.11.5/openedx_learning/contrib → openedx_learning-0.12.0/openedx_learning/apps/authoring/publishing}/__init__.py +0 -0
  45. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/publishing/admin.py +0 -0
  46. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/publishing/api.py +0 -0
  47. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/publishing/apps.py +0 -0
  48. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/publishing/migrations/0001_initial.py +0 -0
  49. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/publishing/migrations/0002_alter_learningpackage_key_and_more.py +0 -0
  50. {openedx_learning-0.11.5/openedx_learning/lib → openedx_learning-0.12.0/openedx_learning/apps/authoring/publishing/migrations}/__init__.py +0 -0
  51. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/apps/authoring/publishing/models.py +0 -0
  52. {openedx_learning-0.11.5/openedx_tagging/core → openedx_learning-0.12.0/openedx_learning/contrib}/__init__.py +0 -0
  53. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/contrib/media_server/__init__.py +0 -0
  54. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/contrib/media_server/apps.py +0 -0
  55. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/contrib/media_server/urls.py +0 -0
  56. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/contrib/media_server/views.py +0 -0
  57. {openedx_learning-0.11.5/openedx_tagging/core/tagging → openedx_learning-0.12.0/openedx_learning/lib}/__init__.py +0 -0
  58. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/lib/admin_utils.py +0 -0
  59. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/lib/cache.py +0 -0
  60. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/lib/collations.py +0 -0
  61. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/lib/fields.py +0 -0
  62. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/lib/managers.py +0 -0
  63. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/lib/test_utils.py +0 -0
  64. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/lib/validators.py +0 -0
  65. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning/py.typed +0 -0
  66. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning.egg-info/dependency_links.txt +0 -0
  67. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning.egg-info/not-zip-safe +0 -0
  68. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning.egg-info/requires.txt +1 -1
  69. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_learning.egg-info/top_level.txt +0 -0
  70. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/__init__.py +0 -0
  71. {openedx_learning-0.11.5/openedx_tagging/core/tagging/migrations → openedx_learning-0.12.0/openedx_tagging/core}/__init__.py +0 -0
  72. {openedx_learning-0.11.5/openedx_tagging/core/tagging/rest_api → openedx_learning-0.12.0/openedx_tagging/core/tagging}/__init__.py +0 -0
  73. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/admin.py +0 -0
  74. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/api.py +0 -0
  75. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/apps.py +0 -0
  76. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/data.py +0 -0
  77. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/import_export/__init__.py +0 -0
  78. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/import_export/actions.py +0 -0
  79. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/import_export/api.py +0 -0
  80. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/import_export/exceptions.py +0 -0
  81. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/import_export/import_plan.py +0 -0
  82. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/import_export/parsers.py +0 -0
  83. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/import_export/tasks.py +0 -0
  84. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/import_export/template.csv +0 -0
  85. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/import_export/template.json +0 -0
  86. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0001_initial.py +0 -0
  87. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0001_squashed.py +0 -0
  88. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0002_auto_20230718_2026.py +0 -0
  89. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0003_auto_20230721_1238.py +0 -0
  90. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0004_auto_20230723_2001.py +0 -0
  91. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0005_language_taxonomy.py +0 -0
  92. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0006_alter_objecttag_unique_together.py +0 -0
  93. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0006_auto_20230802_1631.py +0 -0
  94. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0007_tag_import_task_log_null_fix.py +0 -0
  95. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0008_taxonomy_description_not_null.py +0 -0
  96. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0009_alter_objecttag_object_id.py +0 -0
  97. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0010_cleanups.py +0 -0
  98. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0011_remove_required.py +0 -0
  99. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0012_language_taxonomy.py +0 -0
  100. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0013_tag_parent_blank.py +0 -0
  101. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0014_minor_fixes.py +0 -0
  102. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0015_taxonomy_export_id.py +0 -0
  103. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0016_object_tag_export_id.py +0 -0
  104. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/migrations/0017_alter_tagimporttask_status.py +0 -0
  105. {openedx_learning-0.11.5/openedx_tagging/core/tagging/rest_api/v1 → openedx_learning-0.12.0/openedx_tagging/core/tagging/migrations}/__init__.py +0 -0
  106. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/models/__init__.py +0 -0
  107. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/models/base.py +0 -0
  108. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/models/import_export.py +0 -0
  109. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/models/system_defined.py +0 -0
  110. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/models/utils.py +0 -0
  111. /openedx_learning-0.11.5/openedx_tagging/py.typed → /openedx_learning-0.12.0/openedx_tagging/core/tagging/rest_api/__init__.py +0 -0
  112. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/rest_api/paginators.py +0 -0
  113. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/rest_api/urls.py +0 -0
  114. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/rest_api/utils.py +0 -0
  115. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/rest_api/v1/permissions.py +0 -0
  116. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/rest_api/v1/serializers.py +0 -0
  117. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/rest_api/v1/urls.py +0 -0
  118. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/rest_api/v1/views.py +0 -0
  119. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/rest_api/v1/views_import.py +0 -0
  120. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/rules.py +0 -0
  121. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/openedx_tagging/core/tagging/urls.py +0 -0
  122. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/requirements/base.in +0 -0
  123. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/setup.cfg +0 -0
  124. {openedx_learning-0.11.5 → openedx_learning-0.12.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: openedx-learning
3
- Version: 0.11.5
3
+ Version: 0.12.0
4
4
  Summary: Open edX Learning Core and Tagging.
5
5
  Home-page: https://github.com/openedx/openedx-learning
6
6
  Author: David Ormsbee
@@ -20,10 +20,10 @@ Requires-Python: >=3.11
20
20
  License-File: LICENSE.txt
21
21
  Requires-Dist: celery
22
22
  Requires-Dist: Django<5.0
23
- Requires-Dist: attrs
24
23
  Requires-Dist: djangorestframework<4.0
25
24
  Requires-Dist: rules<4.0
26
25
  Requires-Dist: edx-drf-extensions
26
+ Requires-Dist: attrs
27
27
 
28
28
  Open edX Learning Core (and Tagging)
29
29
  ====================================
@@ -1,4 +1,4 @@
1
1
  """
2
2
  Open edX Learning ("Learning Core").
3
3
  """
4
- __version__ = "0.11.5"
4
+ __version__ = "0.12.0"
@@ -13,11 +13,16 @@ are stored in this app.
13
13
  from __future__ import annotations
14
14
 
15
15
  from datetime import datetime
16
+ from enum import StrEnum, auto
17
+ from logging import getLogger
16
18
  from pathlib import Path
19
+ from uuid import UUID
17
20
 
18
21
  from django.db.models import Q, QuerySet
19
22
  from django.db.transaction import atomic
23
+ from django.http.response import HttpResponse, HttpResponseNotFound
20
24
 
25
+ from ..contents import api as contents_api
21
26
  from ..publishing import api as publishing_api
22
27
  from .models import Component, ComponentType, ComponentVersion, ComponentVersionContent
23
28
 
@@ -34,12 +39,20 @@ __all__ = [
34
39
  "create_component_and_version",
35
40
  "get_component",
36
41
  "get_component_by_key",
42
+ "get_component_by_uuid",
43
+ "get_component_version_by_uuid",
37
44
  "component_exists_by_key",
38
45
  "get_components",
39
46
  "create_component_version_content",
47
+ "look_up_component_version_content",
48
+ "AssetError",
49
+ "get_redirect_response_for_component_asset",
40
50
  ]
41
51
 
42
52
 
53
+ logger = getLogger()
54
+
55
+
43
56
  def get_or_create_component_type(namespace: str, name: str) -> ComponentType:
44
57
  """
45
58
  Get the ID of a ComponentType, and create if missing.
@@ -112,9 +125,9 @@ def create_component_version(
112
125
  def create_next_component_version(
113
126
  component_pk: int,
114
127
  /,
115
- title: str,
116
128
  content_to_replace: dict[str, int | None],
117
129
  created: datetime,
130
+ title: str | None = None,
118
131
  created_by: int | None = None,
119
132
  ) -> ComponentVersion:
120
133
  """
@@ -150,8 +163,11 @@ def create_next_component_version(
150
163
  last_version = component.versioning.latest
151
164
  if last_version is None:
152
165
  next_version_num = 1
166
+ title = title or ""
153
167
  else:
154
168
  next_version_num = last_version.version_num + 1
169
+ if title is None:
170
+ title = last_version.title
155
171
 
156
172
  with atomic():
157
173
  publishable_entity_version = publishing_api.create_publishable_entity_version(
@@ -247,6 +263,14 @@ def get_component_by_key(
247
263
  )
248
264
 
249
265
 
266
+ def get_component_by_uuid(uuid: UUID) -> Component:
267
+ return Component.with_publishing_relations.get(publishable_entity__uuid=uuid)
268
+
269
+
270
+ def get_component_version_by_uuid(uuid: UUID) -> ComponentVersion:
271
+ return ComponentVersion.objects.get(publishable_entity_version__uuid=uuid)
272
+
273
+
250
274
  def component_exists_by_key(
251
275
  learning_package_id: int,
252
276
  /,
@@ -351,7 +375,7 @@ def create_component_version_content(
351
375
  content_id: int,
352
376
  /,
353
377
  key: str,
354
- learner_downloadable=False,
378
+ learner_downloadable: bool = False,
355
379
  ) -> ComponentVersionContent:
356
380
  """
357
381
  Add a Content to the given ComponentVersion
@@ -363,3 +387,177 @@ def create_component_version_content(
363
387
  learner_downloadable=learner_downloadable,
364
388
  )
365
389
  return cvrc
390
+
391
+
392
+ class AssetError(StrEnum):
393
+ """Error codes related to fetching ComponentVersion assets."""
394
+ ASSET_PATH_NOT_FOUND_FOR_COMPONENT_VERSION = auto()
395
+ ASSET_NOT_LEARNER_DOWNLOADABLE = auto()
396
+ ASSET_HAS_NO_DOWNLOAD_FILE = auto()
397
+
398
+
399
+ def _get_component_version_info_headers(component_version: ComponentVersion) -> dict[str, str]:
400
+ """
401
+ These are the headers we can derive based on a valid ComponentVersion.
402
+
403
+ These headers are intended to ease development and debugging, by showing
404
+ where this static asset is coming from. These headers will work even if
405
+ the asset path does not exist for this particular ComponentVersion.
406
+ """
407
+ component = component_version.component
408
+ learning_package = component.learning_package
409
+ return {
410
+ # Component
411
+ "X-Open-edX-Component-Key": component.publishable_entity.key,
412
+ "X-Open-edX-Component-Uuid": component.uuid,
413
+ # Component Version
414
+ "X-Open-edX-Component-Version-Uuid": component_version.uuid,
415
+ "X-Open-edX-Component-Version-Num": component_version.version_num,
416
+ # Learning Package
417
+ "X-Open-edX-Learning-Package-Key": learning_package.key,
418
+ "X-Open-edX-Learning-Package-Uuid": learning_package.uuid,
419
+ }
420
+
421
+
422
+ def get_redirect_response_for_component_asset(
423
+ component_version_uuid: UUID,
424
+ asset_path: Path,
425
+ public: bool = False,
426
+ learner_downloadable_only: bool = True,
427
+ ) -> HttpResponse:
428
+ """
429
+ ``HttpResponse`` for a reverse-proxy to serve a ``ComponentVersion`` asset.
430
+
431
+ :param component_version_uuid: ``UUID`` of the ``ComponentVersion`` that the
432
+ asset is part of.
433
+
434
+ :param asset_path: Path to the asset being requested.
435
+
436
+ :param public: Is this asset going to be made available without auth checks?
437
+ If ``True``, this will return an ``HttpResponse`` that can be cached in
438
+ a CDN and shared across many clients.
439
+
440
+ :param learner_downloadable_only: Only return assets that are meant to be
441
+ downloadable by Learners, i.e. in the LMS experience. If this is
442
+ ``True``, then requests for assets that are not meant for student
443
+ download will return a ``404`` error response.
444
+
445
+ **Response Codes**
446
+
447
+ If the asset exists for this ``ComponentVersion``, this function will return
448
+ an ``HttpResponse`` with a status code of ``200``.
449
+
450
+ If the specified asset does not exist for this ``ComponentVersion``, or if
451
+ the ``ComponentVersion`` itself does not exist, the response code will be
452
+ ``404``.
453
+
454
+ Other than checking the coarse-grained ``learner_downloadable_only`` flag,
455
+ *this function does not do auth checking of any sort*–it will never return
456
+ a ``401`` or ``403`` response code. That is by design. Figuring out who is
457
+ making the request and whether they have permission to do so is the
458
+ responsiblity of whatever is calling this function. The
459
+ ``learner_downloadable_only`` flag is intended to be a filter for the entire
460
+ view. When it's True, not even staff can download component-internal assets.
461
+ This is intended to protect us from accidentally allowing sensitive grading
462
+ code to get leaked out.
463
+
464
+ **Metadata Headers**
465
+
466
+ The ``HttpResponse`` returned by this function will have headers describing
467
+ the asset and the ``ComponentVersion`` it belongs to (if it exists):
468
+
469
+ * ``Content-Type``
470
+ * ``Etag`` (this will be the asset's hash digest)
471
+ * ``X-Open-edX-Component-Key``
472
+ * ``X-Open-edX-Component-Uuid``
473
+ * ``X-Open-edX-Component-Version-Uuid``
474
+ * ``X-Open-edX-Component-Version-Num``
475
+ * ``X-Open-edX-Learning-Package-Key``
476
+ * ``X-Open-edX-Learning-Package-Uuid``
477
+
478
+ **Asset Redirection**
479
+
480
+ For performance reasons, the ``HttpResponse`` object returned by this
481
+ function does not contain the actual content data of the asset. It requires
482
+ an appropriately configured reverse proxy server that handles the
483
+ ``X-Accel-Redirect`` header (both Caddy and Nginx support this).
484
+
485
+ .. warning::
486
+ If you add any headers here, you may need to add them in the "media"
487
+ service container's reverse proxy configuration. In Tutor, this is a
488
+ Caddyfile. All non-standard HTTP headers should be prefixed with
489
+ ``X-Open-edX-``.
490
+ """
491
+ # Helper to generate error header messages.
492
+ def _error_header(error: AssetError) -> dict[str, str]:
493
+ return {"X-Open-edX-Error": str(error)}
494
+
495
+ # Check: Does the ComponentVersion exist?
496
+ try:
497
+ component_version = get_component_version_by_uuid(component_version_uuid)
498
+ except ComponentVersion.DoesNotExist:
499
+ # No need to add headers here, because no ComponentVersion was found.
500
+ logger.error(f"Asset Not Found: No ComponentVersion with UUID {component_version_uuid}")
501
+ return HttpResponseNotFound()
502
+
503
+ # At this point we know that the ComponentVersion exists, so we can build
504
+ # those headers...
505
+ info_headers = _get_component_version_info_headers(component_version)
506
+
507
+ # Check: Does the ComponentVersion have the requested asset (Content)?
508
+ try:
509
+ cv_content = component_version.componentversioncontent_set.get(key=asset_path)
510
+ except ComponentVersionContent.DoesNotExist:
511
+ logger.error(f"ComponentVersion {component_version_uuid} has no asset {asset_path}")
512
+ info_headers.update(
513
+ _error_header(AssetError.ASSET_PATH_NOT_FOUND_FOR_COMPONENT_VERSION)
514
+ )
515
+ return HttpResponseNotFound(headers=info_headers)
516
+
517
+ # Check: Does the Content have a downloadable file, instead of just inline
518
+ # text? It's easy for us to grab this content and stream it to the user
519
+ # anyway, but we're explicitly not doing so because streaming large text
520
+ # fields from the database is less scalable, and we don't want to encourage
521
+ # that usage pattern.
522
+ content = cv_content.content
523
+ if not content.has_file:
524
+ logger.error(
525
+ f"ComponentVersion {component_version_uuid} has asset {asset_path}, "
526
+ "but it is not downloadable (has_file=False)."
527
+ )
528
+ info_headers.update(
529
+ _error_header(AssetError.ASSET_HAS_NO_DOWNLOAD_FILE)
530
+ )
531
+ return HttpResponseNotFound(headers=info_headers)
532
+
533
+ # Check: If we're asking only for Learner Downloadable assets, and the asset
534
+ # in question is not supposed to be downloadable by learners, then we give a
535
+ # 404 error. Even staff members are not expected to be able to download
536
+ # these assets via the LMS endpoint that serves students. Studio would be
537
+ # expected to have an entirely different view to serve these assets in that
538
+ # context (along with different timeouts, auth, and cache settings). So in
539
+ # that sense, the asset doesn't exist for that particular endpoint.
540
+ if learner_downloadable_only and (not cv_content.learner_downloadable):
541
+ logger.error(
542
+ f"ComponentVersion {component_version_uuid} has asset {asset_path}, "
543
+ "but it is not meant to be downloadable by learners "
544
+ "(ComponentVersionContent.learner_downloadable=False)."
545
+ )
546
+ info_headers.update(
547
+ _error_header(AssetError.ASSET_NOT_LEARNER_DOWNLOADABLE)
548
+ )
549
+ return HttpResponseNotFound(headers=info_headers)
550
+
551
+ # At this point, we know that there is valid Content that we want to send.
552
+ # This adds Content-level headers, like the hash/etag and content type.
553
+ info_headers.update(contents_api.get_content_info_headers(content))
554
+ stored_file_path = content.file_path()
555
+
556
+ # Recompute redirect headers (reminder: this should never be cached).
557
+ redirect_headers = contents_api.get_redirect_headers(stored_file_path, public)
558
+ logger.info(
559
+ "Asset redirect (uncached metadata): "
560
+ f"{component_version_uuid}/{asset_path} -> {redirect_headers}"
561
+ )
562
+
563
+ return HttpResponse(headers={**info_headers, **redirect_headers})
@@ -0,0 +1,111 @@
1
+ """
2
+ Management command to add files to a Component.
3
+
4
+ This is mostly meant to be a debugging tool to let us to easily load some test
5
+ asset data into the system.
6
+ """
7
+ import mimetypes
8
+ import pathlib
9
+ from datetime import datetime, timezone
10
+
11
+ from django.core.management.base import BaseCommand
12
+
13
+ from ....components.api import create_component_version_content
14
+ from ....contents.api import get_or_create_file_content, get_or_create_media_type
15
+ from ....publishing.api import get_learning_package_by_key
16
+ from ...api import create_next_component_version, get_component_by_key
17
+
18
+
19
+ class Command(BaseCommand):
20
+ """
21
+ Add files to a Component, creating a new Component Version.
22
+
23
+ This does not publish the the Component.
24
+
25
+ Note: This is a quick debug tool meant to stuff some asset data into
26
+ Learning Core models for testing. It's not intended as a robust and
27
+ performant tool for modifying actual production content, and should not be
28
+ used for that purpose.
29
+ """
30
+
31
+ def add_arguments(self, parser):
32
+ parser.add_argument(
33
+ "learning_package_key",
34
+ type=str,
35
+ help="LearningPackage.key value for where the Component is located."
36
+ )
37
+ parser.add_argument(
38
+ "component_key",
39
+ type=str,
40
+ help="Component.key that you want to add assets to."
41
+ )
42
+ parser.add_argument(
43
+ "file_mappings",
44
+ nargs="+",
45
+ type=str,
46
+ help=(
47
+ "Mappings of desired Component asset paths to the disk paths "
48
+ "of where to upload the file from, separated by ':'. (Example: "
49
+ "static/donkey.jpg:/Users/dave/Desktop/donkey-big.jpg). A "
50
+ "blank value for upload file means to remove that from the "
51
+ "Component. You may upload/remove as many files as you want in "
52
+ "a single invocation."
53
+ )
54
+ )
55
+
56
+ def handle(self, *args, **options):
57
+ """
58
+ Add files to a Component as ComponentVersion -> Content associations.
59
+ """
60
+ learning_package_key = options["learning_package_key"]
61
+ component_key = options["component_key"]
62
+ file_mappings = options["file_mappings"]
63
+
64
+ learning_package = get_learning_package_by_key(learning_package_key)
65
+ # Parse something like: "xblock.v1:problem:area_of_circle_1"
66
+ namespace, type_name, local_key = component_key.split(":", 2)
67
+ component = get_component_by_key(
68
+ learning_package.id, namespace, type_name, local_key
69
+ )
70
+
71
+ created = datetime.now(tz=timezone.utc)
72
+ keys_to_remove = set()
73
+ local_keys_to_content = {}
74
+
75
+ for file_mapping in file_mappings:
76
+ local_key, file_path = file_mapping.split(":", 1)
77
+
78
+ # No file_path means to delete this entry from the next version.
79
+ if not file_path:
80
+ keys_to_remove.add(local_key)
81
+ continue
82
+
83
+ media_type_str, _encoding = mimetypes.guess_type(file_path)
84
+ media_type = get_or_create_media_type(media_type_str)
85
+ content = get_or_create_file_content(
86
+ learning_package.id,
87
+ media_type.id,
88
+ data=pathlib.Path(file_path).read_bytes(),
89
+ created=created,
90
+ )
91
+ local_keys_to_content[local_key] = content.id
92
+
93
+ next_version = create_next_component_version(
94
+ component.pk,
95
+ content_to_replace={local_key: None for local_key in keys_to_remove},
96
+ created=created,
97
+ )
98
+ for local_key, content_id in sorted(local_keys_to_content.items()):
99
+ create_component_version_content(
100
+ next_version.pk,
101
+ content_id,
102
+ key=local_key,
103
+ learner_downloadable=True,
104
+ )
105
+
106
+ self.stdout.write(
107
+ f"Created v{next_version.version_num} of "
108
+ f"{next_version.component.key} ({next_version.uuid}):"
109
+ )
110
+ for cvc in next_version.componentversioncontent_set.all():
111
+ self.stdout.write(f"- {cvc.key} ({cvc.uuid})")
@@ -7,6 +7,7 @@ are stored in this app.
7
7
  from __future__ import annotations
8
8
 
9
9
  from datetime import datetime
10
+ from logging import getLogger
10
11
 
11
12
  from django.core.files.base import ContentFile
12
13
  from django.db.transaction import atomic
@@ -22,11 +23,15 @@ from .models import Content, MediaType
22
23
  __all__ = [
23
24
  "get_or_create_media_type",
24
25
  "get_content",
26
+ "get_content_info_headers",
25
27
  "get_or_create_text_content",
26
28
  "get_or_create_file_content",
27
29
  ]
28
30
 
29
31
 
32
+ log = getLogger()
33
+
34
+
30
35
  def get_or_create_media_type(mime_type: str) -> MediaType:
31
36
  """
32
37
  Return the MediaType.id for the desired mime_type string.
@@ -168,3 +173,67 @@ def get_or_create_file_content(
168
173
  content.write_file(ContentFile(data))
169
174
 
170
175
  return content
176
+
177
+
178
+ def get_content_info_headers(content: Content) -> dict[str, str]:
179
+ """
180
+ Return HTTP headers that are specific to this Content.
181
+
182
+ This currently only consists of the Content-Type and ETag. These values are
183
+ safe to cache.
184
+ """
185
+ return {
186
+ "Content-Type": str(content.media_type),
187
+ "Etag": content.hash_digest,
188
+ }
189
+
190
+
191
+ def get_redirect_headers(
192
+ stored_file_path: str,
193
+ public: bool = False,
194
+ max_age: int | None = None,
195
+ ) -> dict[str, str]:
196
+ """
197
+ Return a dict of headers for file redirect and caching.
198
+
199
+ This is a separate function from get_content_info_headers because the URLs
200
+ returned in these headers produced by this function should never be put into
201
+ the backend Django cache (redis/memcached). The `stored_file_path` location
202
+ *is* cacheable though–that's the actual storage location for the resource,
203
+ and not a link that could potentially expire.
204
+
205
+ TODO: We need to add support for short-lived URL generation from the
206
+ stored_file_path.
207
+ """
208
+ if public:
209
+ # If an asset is public, then let it be cached by the reverse-proxy and
210
+ # CDN, but do require that it be revalidated after the suggested max
211
+ # age. This would help us do things like take a URL that was mistakenly
212
+ # made public and make it require authentication. Fortunately, checking
213
+ # that the content is up to date is a cheap operation, since it just
214
+ # requires examining the Etag.
215
+ cache_directive = "must-revalidate"
216
+
217
+ # Default to an hour of caching, to make it easier to tighten access
218
+ # later on.
219
+ max_age = max_age or (5 * 60)
220
+ else:
221
+ # If an asset is meant to be private, that means this response should
222
+ # not be cached by either the reverse-proxy or any CDN–it's only ever
223
+ # cached on the user's browser. This is what you'd use for very granular
224
+ # permissions checking, e.g. "only let them see this image if they have
225
+ # access to the Component it's associated with". Note that we're not
226
+ # doing ``Vary: Cookie`` because that would fill the reverse-proxy and
227
+ # CDN caches with a lot of redundant entries.
228
+ cache_directive = "private"
229
+
230
+ # This only stays on the user's browser, so cache for a whole day. This
231
+ # is okay to do because Content data is typically immutable–i.e. if an
232
+ # asset actually changes, the user should be directed to a different URL
233
+ # for it.
234
+ max_age = max_age or (60 * 60 * 24)
235
+
236
+ return {
237
+ "Cache-Control": f"max-age={max_age}, {cache_directive}",
238
+ "X-Accel-Redirect": stored_file_path,
239
+ }
@@ -5,7 +5,7 @@ more intelligent data models to be useful.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
- from functools import cached_property
8
+ from functools import cache, cached_property
9
9
 
10
10
  from django.core.exceptions import ValidationError
11
11
  from django.core.files.base import File
@@ -23,6 +23,7 @@ __all__ = [
23
23
  ]
24
24
 
25
25
 
26
+ @cache
26
27
  def get_storage() -> Storage:
27
28
  """
28
29
  Return the Storage instance for our Content file persistence.
@@ -236,6 +237,10 @@ class Content(models.Model):
236
237
  hash_digest = hash_field()
237
238
 
238
239
  # Do we have file data stored for this Content in our file storage backend?
240
+ # We use has_file instead of a FileField because it's more space efficient.
241
+ # The location of a Content's file data is derivable from the Learning
242
+ # Package's UUID and the hash of the Content. There's no need to waste that
243
+ # space to encode it in every row.
239
244
  has_file = models.BooleanField()
240
245
 
241
246
  # The ``text`` field contains the text representation of the Content, if
@@ -288,6 +293,8 @@ class Content(models.Model):
288
293
  def write_file(self, file: File) -> None:
289
294
  """
290
295
  Write file contents to the file storage backend.
296
+
297
+ This function does nothing if the file already exists.
291
298
  """
292
299
  storage = get_storage()
293
300
  file_path = self.file_path()
@@ -303,14 +310,19 @@ class Content(models.Model):
303
310
  # be two logically separate Content entries if they are different file
304
311
  # types. This lets other models add data to Content via 1:1 relations by
305
312
  # ContentType (e.g. all SRT files). This is definitely an edge case.
306
- if not storage.exists(file_path):
307
- storage.save(file_path, file)
313
+ #
314
+ # 3. Similar to (2), but only part of the file was written before an
315
+ # error occurred. This seems unlikely, but possible if the underlying
316
+ # storage engine writes in chunks.
317
+ if storage.exists(file_path) and storage.size(file_path) == file.size:
318
+ return
319
+ storage.save(file_path, file)
308
320
 
309
321
  def file_url(self) -> str:
310
322
  """
311
323
  This will sometimes be a time-limited signed URL.
312
324
  """
313
- return get_storage().url(self.file_path())
325
+ return content_file_url(self.file_path())
314
326
 
315
327
  def clean(self):
316
328
  """
@@ -349,3 +361,7 @@ class Content(models.Model):
349
361
  ]
350
362
  verbose_name = "Content"
351
363
  verbose_name_plural = "Contents"
364
+
365
+
366
+ def content_file_url(file_path):
367
+ return get_storage().url(file_path)
@@ -272,6 +272,16 @@ class PublishableEntityMixin(models.Model):
272
272
  publishable_entity_version__entity_id=pub_ent.id
273
273
  )
274
274
 
275
+ def version_num(self, version_num):
276
+ """
277
+ Return a specific numbered version model.
278
+ """
279
+ pub_ent = self.content_obj.publishable_entity
280
+ return self.content_version_model_cls.objects.get(
281
+ publishable_entity_version__entity_id=pub_ent.id,
282
+ publishable_entity_version__version_num=version_num,
283
+ )
284
+
275
285
 
276
286
  class PublishableEntityVersionMixin(models.Model):
277
287
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: openedx-learning
3
- Version: 0.11.5
3
+ Version: 0.12.0
4
4
  Summary: Open edX Learning Core and Tagging.
5
5
  Home-page: https://github.com/openedx/openedx-learning
6
6
  Author: David Ormsbee
@@ -20,10 +20,10 @@ Requires-Python: >=3.11
20
20
  License-File: LICENSE.txt
21
21
  Requires-Dist: celery
22
22
  Requires-Dist: Django<5.0
23
- Requires-Dist: attrs
24
23
  Requires-Dist: djangorestframework<4.0
25
24
  Requires-Dist: rules<4.0
26
25
  Requires-Dist: edx-drf-extensions
26
+ Requires-Dist: attrs
27
27
 
28
28
  Open edX Learning Core (and Tagging)
29
29
  ====================================
@@ -31,6 +31,9 @@ openedx_learning/apps/authoring/components/admin.py
31
31
  openedx_learning/apps/authoring/components/api.py
32
32
  openedx_learning/apps/authoring/components/apps.py
33
33
  openedx_learning/apps/authoring/components/models.py
34
+ openedx_learning/apps/authoring/components/management/__init__.py
35
+ openedx_learning/apps/authoring/components/management/commands/__init__.py
36
+ openedx_learning/apps/authoring/components/management/commands/add_assets_to_component.py
34
37
  openedx_learning/apps/authoring/components/migrations/0001_initial.py
35
38
  openedx_learning/apps/authoring/components/migrations/0002_alter_componentversioncontent_key.py
36
39
  openedx_learning/apps/authoring/components/migrations/__init__.py
File without changes
@@ -1,6 +1,6 @@
1
1
  celery
2
2
  Django<5.0
3
- attrs
4
3
  djangorestframework<4.0
5
4
  rules<4.0
6
5
  edx-drf-extensions
6
+ attrs