openedx-learning 0.13.1__tar.gz → 0.14.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 (126) hide show
  1. {openedx_learning-0.13.1/openedx_learning.egg-info → openedx_learning-0.14.0}/PKG-INFO +4 -4
  2. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/__init__.py +1 -1
  3. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/components/admin.py +17 -25
  4. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/components/api.py +30 -4
  5. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/contents/admin.py +29 -10
  6. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/contents/models.py +61 -20
  7. {openedx_learning-0.13.1 → openedx_learning-0.14.0/openedx_learning.egg-info}/PKG-INFO +4 -4
  8. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/import_export/api.py +1 -1
  9. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/CHANGELOG.rst +0 -0
  10. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/LICENSE.txt +0 -0
  11. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/MANIFEST.in +0 -0
  12. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/README.rst +0 -0
  13. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/api/__init__.py +0 -0
  14. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/api/authoring.py +0 -0
  15. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/api/authoring_models.py +0 -0
  16. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/__init__.py +0 -0
  17. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/__init__.py +0 -0
  18. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/collections/__init__.py +0 -0
  19. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/collections/admin.py +0 -0
  20. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/collections/api.py +0 -0
  21. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/collections/apps.py +0 -0
  22. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/collections/migrations/0001_initial.py +0 -0
  23. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/collections/migrations/0002_remove_collection_name_collection_created_by_and_more.py +0 -0
  24. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/collections/migrations/0003_collection_entities.py +0 -0
  25. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/collections/migrations/0004_collection_key.py +0 -0
  26. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/collections/migrations/0005_alter_collection_options_alter_collection_enabled.py +0 -0
  27. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/collections/migrations/__init__.py +0 -0
  28. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/collections/models.py +0 -0
  29. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/components/__init__.py +0 -0
  30. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/components/apps.py +0 -0
  31. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/components/management/__init__.py +0 -0
  32. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/components/management/commands/__init__.py +0 -0
  33. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/components/management/commands/add_assets_to_component.py +0 -0
  34. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/components/migrations/0001_initial.py +0 -0
  35. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/components/migrations/0002_alter_componentversioncontent_key.py +0 -0
  36. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/components/migrations/__init__.py +0 -0
  37. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/components/models.py +0 -0
  38. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/contents/__init__.py +0 -0
  39. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/contents/api.py +0 -0
  40. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/contents/apps.py +0 -0
  41. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/contents/migrations/0001_initial.py +0 -0
  42. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/contents/migrations/__init__.py +0 -0
  43. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/publishing/__init__.py +0 -0
  44. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/publishing/admin.py +0 -0
  45. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/publishing/api.py +0 -0
  46. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/publishing/apps.py +0 -0
  47. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/publishing/migrations/0001_initial.py +0 -0
  48. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/publishing/migrations/0002_alter_learningpackage_key_and_more.py +0 -0
  49. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/publishing/migrations/__init__.py +0 -0
  50. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/publishing/model_mixins.py +0 -0
  51. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/apps/authoring/publishing/models.py +0 -0
  52. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/contrib/__init__.py +0 -0
  53. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/contrib/media_server/__init__.py +0 -0
  54. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/contrib/media_server/apps.py +0 -0
  55. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/contrib/media_server/urls.py +0 -0
  56. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/contrib/media_server/views.py +0 -0
  57. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/lib/__init__.py +0 -0
  58. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/lib/admin_utils.py +0 -0
  59. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/lib/cache.py +0 -0
  60. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/lib/collations.py +0 -0
  61. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/lib/fields.py +0 -0
  62. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/lib/managers.py +0 -0
  63. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/lib/test_utils.py +0 -0
  64. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/lib/validators.py +0 -0
  65. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning/py.typed +0 -0
  66. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning.egg-info/SOURCES.txt +0 -0
  67. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning.egg-info/dependency_links.txt +0 -0
  68. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning.egg-info/not-zip-safe +0 -0
  69. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning.egg-info/requires.txt +3 -3
  70. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_learning.egg-info/top_level.txt +0 -0
  71. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/__init__.py +0 -0
  72. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/__init__.py +0 -0
  73. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/__init__.py +0 -0
  74. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/admin.py +0 -0
  75. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/api.py +0 -0
  76. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/apps.py +0 -0
  77. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/data.py +0 -0
  78. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/import_export/__init__.py +0 -0
  79. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/import_export/actions.py +0 -0
  80. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/import_export/exceptions.py +0 -0
  81. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/import_export/import_plan.py +0 -0
  82. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/import_export/parsers.py +0 -0
  83. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/import_export/tasks.py +0 -0
  84. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/import_export/template.csv +0 -0
  85. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/import_export/template.json +0 -0
  86. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0001_initial.py +0 -0
  87. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0001_squashed.py +0 -0
  88. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0002_auto_20230718_2026.py +0 -0
  89. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0003_auto_20230721_1238.py +0 -0
  90. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0004_auto_20230723_2001.py +0 -0
  91. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0005_language_taxonomy.py +0 -0
  92. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0006_alter_objecttag_unique_together.py +0 -0
  93. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0006_auto_20230802_1631.py +0 -0
  94. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0007_tag_import_task_log_null_fix.py +0 -0
  95. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0008_taxonomy_description_not_null.py +0 -0
  96. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0009_alter_objecttag_object_id.py +0 -0
  97. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0010_cleanups.py +0 -0
  98. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0011_remove_required.py +0 -0
  99. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0012_language_taxonomy.py +0 -0
  100. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0013_tag_parent_blank.py +0 -0
  101. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0014_minor_fixes.py +0 -0
  102. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0015_taxonomy_export_id.py +0 -0
  103. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0016_object_tag_export_id.py +0 -0
  104. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/0017_alter_tagimporttask_status.py +0 -0
  105. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/migrations/__init__.py +0 -0
  106. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/models/__init__.py +0 -0
  107. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/models/base.py +0 -0
  108. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/models/import_export.py +0 -0
  109. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/models/system_defined.py +0 -0
  110. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/models/utils.py +0 -0
  111. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/rest_api/__init__.py +0 -0
  112. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/rest_api/paginators.py +0 -0
  113. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/rest_api/urls.py +0 -0
  114. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/rest_api/utils.py +0 -0
  115. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/rest_api/v1/__init__.py +0 -0
  116. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/rest_api/v1/permissions.py +0 -0
  117. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/rest_api/v1/serializers.py +0 -0
  118. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/rest_api/v1/urls.py +0 -0
  119. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/rest_api/v1/views.py +0 -0
  120. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/rest_api/v1/views_import.py +0 -0
  121. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/rules.py +0 -0
  122. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/core/tagging/urls.py +0 -0
  123. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/openedx_tagging/py.typed +0 -0
  124. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/requirements/base.in +0 -0
  125. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/setup.cfg +0 -0
  126. {openedx_learning-0.13.1 → openedx_learning-0.14.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: openedx-learning
3
- Version: 0.13.1
3
+ Version: 0.14.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
@@ -19,11 +19,11 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Requires-Python: >=3.11
20
20
  License-File: LICENSE.txt
21
21
  Requires-Dist: attrs
22
- Requires-Dist: Django<5.0
23
- Requires-Dist: djangorestframework<4.0
24
- Requires-Dist: edx-drf-extensions
25
22
  Requires-Dist: celery
23
+ Requires-Dist: djangorestframework<4.0
26
24
  Requires-Dist: rules<4.0
25
+ Requires-Dist: Django<5.0
26
+ Requires-Dist: edx-drf-extensions
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.13.1"
4
+ __version__ = "0.14.0"
@@ -1,6 +1,8 @@
1
1
  """
2
2
  Django admin for components models
3
3
  """
4
+ import base64
5
+
4
6
  from django.contrib import admin
5
7
  from django.template.defaultfilters import filesizeformat
6
8
  from django.urls import reverse
@@ -67,19 +69,22 @@ class ContentInline(admin.TabularInline):
67
69
  )
68
70
 
69
71
  fields = [
70
- "format_key",
72
+ "key",
71
73
  "format_size",
72
74
  "learner_downloadable",
73
75
  "rendered_data",
74
76
  ]
75
77
  readonly_fields = [
76
78
  "content",
77
- "format_key",
79
+ "key",
78
80
  "format_size",
79
81
  "rendered_data",
80
82
  ]
81
83
  extra = 0
82
84
 
85
+ def has_file(self, cvc_obj):
86
+ return cvc_obj.content.has_file
87
+
83
88
  def rendered_data(self, cvc_obj):
84
89
  return content_preview(cvc_obj)
85
90
 
@@ -87,15 +92,6 @@ class ContentInline(admin.TabularInline):
87
92
  def format_size(self, cvc_obj):
88
93
  return filesizeformat(cvc_obj.content.size)
89
94
 
90
- @admin.display(description="Key")
91
- def format_key(self, cvc_obj):
92
- return format_html(
93
- '<a href="{}">{}</a>',
94
- link_for_cvc(cvc_obj),
95
- # reverse("admin:components_content_change", args=(cvc_obj.content_id,)),
96
- cvc_obj.key,
97
- )
98
-
99
95
 
100
96
  @admin.register(ComponentVersion)
101
97
  class ComponentVersionAdmin(ReadOnlyModelAdmin):
@@ -129,18 +125,6 @@ class ComponentVersionAdmin(ReadOnlyModelAdmin):
129
125
  )
130
126
 
131
127
 
132
- def link_for_cvc(cvc_obj: ComponentVersionContent) -> str:
133
- """
134
- Get the download URL for the given ComponentVersionContent instance
135
- """
136
- return "/media_server/component_asset/{}/{}/{}/{}".format(
137
- cvc_obj.content.learning_package.key,
138
- cvc_obj.component_version.component.key,
139
- cvc_obj.component_version.version_num,
140
- cvc_obj.key,
141
- )
142
-
143
-
144
128
  def format_text_for_admin_display(text: str) -> SafeText:
145
129
  """
146
130
  Get the HTML to display the given plain text (preserving formatting)
@@ -158,9 +142,17 @@ def content_preview(cvc_obj: ComponentVersionContent) -> SafeText:
158
142
  content_obj = cvc_obj.content
159
143
 
160
144
  if content_obj.media_type.type == "image":
145
+ # This base64 encoding looks really goofy and is bad for performance,
146
+ # but image previews in the admin are extremely useful, and this lets us
147
+ # have them without creating a separate view in Learning Core. (Keep in
148
+ # mind that these assets are private, so they cannot be accessed via the
149
+ # MEDIA_URL like most Django uploaded assets.)
150
+ data = content_obj.read_file().read()
161
151
  return format_html(
162
- '<img src="{}" style="max-width: 100%;" />',
163
- content_obj.file_url(),
152
+ '<img src="data:{};base64, {}" style="max-width: 100%;" /><br><pre>{}</pre>',
153
+ content_obj.mime_type,
154
+ base64.encodebytes(data).decode('utf8'),
155
+ content_obj.os_path(),
164
156
  )
165
157
 
166
158
  return format_text_for_admin_display(
@@ -269,7 +269,15 @@ def get_component_by_uuid(uuid: UUID) -> Component:
269
269
 
270
270
 
271
271
  def get_component_version_by_uuid(uuid: UUID) -> ComponentVersion:
272
- return ComponentVersion.objects.get(publishable_entity_version__uuid=uuid)
272
+ return (
273
+ ComponentVersion
274
+ .objects
275
+ .select_related(
276
+ "component",
277
+ "component__learning_package",
278
+ )
279
+ .get(publishable_entity_version__uuid=uuid)
280
+ )
273
281
 
274
282
 
275
283
  def component_exists_by_key(
@@ -395,7 +403,21 @@ def create_component_version_content(
395
403
  ) -> ComponentVersionContent:
396
404
  """
397
405
  Add a Content to the given ComponentVersion
406
+
407
+ We don't allow keys that would be absolute paths, e.g. ones that start with
408
+ '/'. Storing these causes headaches with building relative paths and because
409
+ of mismatches with things that expect a leading slash and those that don't.
410
+ So for safety and consistency, we strip off leading slashes and emit a
411
+ warning when we do.
398
412
  """
413
+ if key.startswith('/'):
414
+ logger.warning(
415
+ "Absolute paths are not supported: "
416
+ f"removed leading '/' from ComponentVersion {component_version_id} "
417
+ f"content key: {repr(key)} (content_id: {content_id})"
418
+ )
419
+ key = key.lstrip('/')
420
+
399
421
  cvrc, _created = ComponentVersionContent.objects.get_or_create(
400
422
  component_version_id=component_version_id,
401
423
  content_id=content_id,
@@ -510,7 +532,12 @@ def get_redirect_response_for_component_asset(
510
532
 
511
533
  # Check: Does the ComponentVersion exist?
512
534
  try:
513
- component_version = get_component_version_by_uuid(component_version_uuid)
535
+ component_version = (
536
+ ComponentVersion
537
+ .objects
538
+ .select_related("component", "component__learning_package")
539
+ .get(publishable_entity_version__uuid=component_version_uuid)
540
+ )
514
541
  except ComponentVersion.DoesNotExist:
515
542
  # No need to add headers here, because no ComponentVersion was found.
516
543
  logger.error(f"Asset Not Found: No ComponentVersion with UUID {component_version_uuid}")
@@ -567,10 +594,9 @@ def get_redirect_response_for_component_asset(
567
594
  # At this point, we know that there is valid Content that we want to send.
568
595
  # This adds Content-level headers, like the hash/etag and content type.
569
596
  info_headers.update(contents_api.get_content_info_headers(content))
570
- stored_file_path = content.file_path()
571
597
 
572
598
  # Recompute redirect headers (reminder: this should never be cached).
573
- redirect_headers = contents_api.get_redirect_headers(stored_file_path, public)
599
+ redirect_headers = contents_api.get_redirect_headers(content.path, public)
574
600
  logger.info(
575
601
  "Asset redirect (uncached metadata): "
576
602
  f"{component_version_uuid}/{asset_path} -> {redirect_headers}"
@@ -1,6 +1,8 @@
1
1
  """
2
2
  Django admin for contents models
3
3
  """
4
+ import base64
5
+
4
6
  from django.contrib import admin
5
7
  from django.utils.html import format_html
6
8
 
@@ -16,7 +18,6 @@ class ContentAdmin(ReadOnlyModelAdmin):
16
18
  """
17
19
  list_display = [
18
20
  "hash_digest",
19
- "file_link",
20
21
  "learning_package",
21
22
  "media_type",
22
23
  "size",
@@ -29,24 +30,42 @@ class ContentAdmin(ReadOnlyModelAdmin):
29
30
  "media_type",
30
31
  "size",
31
32
  "created",
32
- "file_link",
33
- "text_preview",
34
33
  "has_file",
34
+ "path",
35
+ "os_path",
36
+ "text_preview",
37
+ "image_preview",
35
38
  ]
36
39
  list_filter = ("media_type", "learning_package")
37
40
  search_fields = ("hash_digest",)
38
41
 
39
- def file_link(self, content: Content):
40
- if not content.has_file:
41
- return ""
42
+ @admin.display(description="OS Path")
43
+ def os_path(self, content: Content):
44
+ return content.os_path() or ""
42
45
 
43
- return format_html(
44
- '<a href="{}">Download</a>',
45
- content.file_url(),
46
- )
46
+ def path(self, content: Content):
47
+ return content.path if content.has_file else ""
47
48
 
48
49
  def text_preview(self, content: Content):
50
+ if not content.text:
51
+ return ""
49
52
  return format_html(
50
53
  '<pre style="white-space: pre-wrap;">\n{}\n</pre>',
51
54
  content.text,
52
55
  )
56
+
57
+ def image_preview(self, content: Content):
58
+ """
59
+ Return HTML for an image, if that is the underlying Content.
60
+
61
+ Otherwise, just return a blank string.
62
+ """
63
+ if content.media_type.type != "image":
64
+ return ""
65
+
66
+ data = content.read_file().read()
67
+ return format_html(
68
+ '<img src="data:{};base64, {}" style="max-width: 100%;" />',
69
+ content.mime_type,
70
+ base64.encodebytes(data).decode('utf8'),
71
+ )
@@ -7,11 +7,13 @@ from __future__ import annotations
7
7
 
8
8
  from functools import cache, cached_property
9
9
 
10
- from django.core.exceptions import ValidationError
10
+ from django.conf import settings
11
+ from django.core.exceptions import ImproperlyConfigured, ValidationError
11
12
  from django.core.files.base import File
12
- from django.core.files.storage import Storage, default_storage
13
+ from django.core.files.storage import Storage
13
14
  from django.core.validators import MaxValueValidator
14
15
  from django.db import models
16
+ from django.utils.module_loading import import_string
15
17
 
16
18
  from ....lib.fields import MultiCollationTextField, case_insensitive_char_field, hash_field, manual_date_time_field
17
19
  from ....lib.managers import WithRelationsManager
@@ -28,13 +30,25 @@ def get_storage() -> Storage:
28
30
  """
29
31
  Return the Storage instance for our Content file persistence.
30
32
 
31
- For right now, we're still only storing inline text and not static assets in
32
- production, so just return the default_storage. We're also going through a
33
- transition between Django 3.2 -> 4.2, where storage configuration has moved.
33
+ This will first search for an OPENEDX_LEARNING config dictionary and return
34
+ a Storage subclass based on that configuration.
34
35
 
35
- Make this work properly as part of adding support for static assets.
36
+ If there is no value for the OPENEDX_LEARNING setting, we return the default
37
+ MEDIA storage class. TODO: Should we make it just error instead?
36
38
  """
37
- return default_storage
39
+ config_dict = getattr(settings, 'OPENEDX_LEARNING', {})
40
+
41
+ if 'MEDIA' in config_dict:
42
+ storage_cls = import_string(config_dict['MEDIA']['BACKEND'])
43
+ options = config_dict['MEDIA'].get('OPTIONS', {})
44
+ return storage_cls(**options)
45
+
46
+ raise ImproperlyConfigured(
47
+ "Cannot access file storage: Missing the OPENEDX_LEARNING['MEDIA'] "
48
+ "setting, which should have a storage BACKEND and OPTIONS values for "
49
+ "a Storage subclass. These files should be stored in a location that "
50
+ "is NOT publicly accessible to browsers (so not in the MEDIA_ROOT)."
51
+ )
38
52
 
39
53
 
40
54
  class MediaType(models.Model):
@@ -282,22 +296,53 @@ class Content(models.Model):
282
296
  """
283
297
  return str(self.media_type)
284
298
 
285
- def file_path(self):
299
+ @cached_property
300
+ def path(self):
301
+ """
302
+ Logical path at which this content is stored (or would be stored).
303
+
304
+ This path is relative to OPENEDX_LEARNING['MEDIA'] configured storage
305
+ root. This file may not exist because has_file=False, or because we
306
+ haven't written the file yet (this is the method we call when trying to
307
+ figure out where the file *should* go).
308
+ """
309
+ return f"content/{self.learning_package.uuid}/{self.hash_digest}"
310
+
311
+ def os_path(self):
312
+ """
313
+ The full OS path for the underlying file for this Content.
314
+
315
+ This will not be supported by all Storage class types.
316
+
317
+ This will return ``None`` if there is no backing file (has_file=False).
286
318
  """
287
- Path at which this content is stored (or would be stored).
319
+ if self.has_file:
320
+ return get_storage().path(self.path)
321
+ return None
288
322
 
289
- This path is relative to configured storage root.
323
+ def read_file(self) -> File:
290
324
  """
291
- return f"{self.learning_package.uuid}/{self.hash_digest}"
325
+ Get a File object that has been open for reading.
326
+
327
+ We intentionally don't expose an `open()` call where callers can open
328
+ this file in write mode. Writing a Content file should happen at most
329
+ once, and the logic is not obvious (see ``write_file``).
330
+
331
+ At the end of the day, the caller can close the returned File and reopen
332
+ it in whatever mode they want, but we're trying to gently discourage
333
+ that kind of usage.
334
+ """
335
+ return get_storage().open(self.path, 'rb')
292
336
 
293
337
  def write_file(self, file: File) -> None:
294
338
  """
295
339
  Write file contents to the file storage backend.
296
340
 
297
- This function does nothing if the file already exists.
341
+ This function does nothing if the file already exists. Note that Content
342
+ is supposed to be immutable, so this should normally only be called once
343
+ for a given Content row.
298
344
  """
299
345
  storage = get_storage()
300
- file_path = self.file_path()
301
346
 
302
347
  # There are two reasons why a file might already exist even if the the
303
348
  # Content row is new:
@@ -314,15 +359,15 @@ class Content(models.Model):
314
359
  # 3. Similar to (2), but only part of the file was written before an
315
360
  # error occurred. This seems unlikely, but possible if the underlying
316
361
  # storage engine writes in chunks.
317
- if storage.exists(file_path) and storage.size(file_path) == file.size:
362
+ if storage.exists(self.path) and storage.size(self.path) == file.size:
318
363
  return
319
- storage.save(file_path, file)
364
+ storage.save(self.path, file)
320
365
 
321
366
  def file_url(self) -> str:
322
367
  """
323
368
  This will sometimes be a time-limited signed URL.
324
369
  """
325
- return content_file_url(self.file_path())
370
+ return get_storage().url(self.path)
326
371
 
327
372
  def clean(self):
328
373
  """
@@ -361,7 +406,3 @@ class Content(models.Model):
361
406
  ]
362
407
  verbose_name = "Content"
363
408
  verbose_name_plural = "Contents"
364
-
365
-
366
- def content_file_url(file_path):
367
- return get_storage().url(file_path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: openedx-learning
3
- Version: 0.13.1
3
+ Version: 0.14.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
@@ -19,11 +19,11 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Requires-Python: >=3.11
20
20
  License-File: LICENSE.txt
21
21
  Requires-Dist: attrs
22
- Requires-Dist: Django<5.0
23
- Requires-Dist: djangorestframework<4.0
24
- Requires-Dist: edx-drf-extensions
25
22
  Requires-Dist: celery
23
+ Requires-Dist: djangorestframework<4.0
26
24
  Requires-Dist: rules<4.0
25
+ Requires-Dist: Django<5.0
26
+ Requires-Dist: edx-drf-extensions
27
27
 
28
28
  Open edX Learning Core (and Tagging)
29
29
  ====================================
@@ -151,7 +151,7 @@ def import_tags(
151
151
  task.end_success(global_elapsed_time)
152
152
 
153
153
  return True, task, tag_import_plan
154
- except Exception as exception:
154
+ except Exception as exception: # pylint: disable=broad-exception-caught
155
155
  # Log any exception
156
156
  task.log_exception(exception)
157
157
  return False, task, None
@@ -1,6 +1,6 @@
1
1
  attrs
2
- Django<5.0
3
- djangorestframework<4.0
4
- edx-drf-extensions
5
2
  celery
3
+ djangorestframework<4.0
6
4
  rules<4.0
5
+ Django<5.0
6
+ edx-drf-extensions