openedx-learning 0.13.1__tar.gz → 0.15.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.15.0}/PKG-INFO +4 -4
  2. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/__init__.py +1 -1
  3. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/components/admin.py +17 -25
  4. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/components/api.py +85 -5
  5. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/contents/admin.py +29 -10
  6. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/contents/models.py +61 -20
  7. {openedx_learning-0.13.1 → openedx_learning-0.15.0/openedx_learning.egg-info}/PKG-INFO +4 -4
  8. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/import_export/api.py +1 -1
  9. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/CHANGELOG.rst +0 -0
  10. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/LICENSE.txt +0 -0
  11. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/MANIFEST.in +0 -0
  12. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/README.rst +0 -0
  13. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/api/__init__.py +0 -0
  14. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/api/authoring.py +0 -0
  15. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/api/authoring_models.py +0 -0
  16. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/__init__.py +0 -0
  17. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/__init__.py +0 -0
  18. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/collections/__init__.py +0 -0
  19. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/collections/admin.py +0 -0
  20. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/collections/api.py +0 -0
  21. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/collections/apps.py +0 -0
  22. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/collections/migrations/0001_initial.py +0 -0
  23. {openedx_learning-0.13.1 → openedx_learning-0.15.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.15.0}/openedx_learning/apps/authoring/collections/migrations/0003_collection_entities.py +0 -0
  25. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/collections/migrations/0004_collection_key.py +0 -0
  26. {openedx_learning-0.13.1 → openedx_learning-0.15.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.15.0}/openedx_learning/apps/authoring/collections/migrations/__init__.py +0 -0
  28. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/collections/models.py +0 -0
  29. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/components/__init__.py +0 -0
  30. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/components/apps.py +0 -0
  31. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/components/management/__init__.py +0 -0
  32. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/components/management/commands/__init__.py +0 -0
  33. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/components/management/commands/add_assets_to_component.py +0 -0
  34. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/components/migrations/0001_initial.py +0 -0
  35. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/components/migrations/0002_alter_componentversioncontent_key.py +0 -0
  36. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/components/migrations/__init__.py +0 -0
  37. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/components/models.py +0 -0
  38. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/contents/__init__.py +0 -0
  39. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/contents/api.py +0 -0
  40. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/contents/apps.py +0 -0
  41. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/contents/migrations/0001_initial.py +0 -0
  42. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/contents/migrations/__init__.py +0 -0
  43. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/publishing/__init__.py +0 -0
  44. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/publishing/admin.py +0 -0
  45. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/publishing/api.py +0 -0
  46. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/publishing/apps.py +0 -0
  47. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/publishing/migrations/0001_initial.py +0 -0
  48. {openedx_learning-0.13.1 → openedx_learning-0.15.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.15.0}/openedx_learning/apps/authoring/publishing/migrations/__init__.py +0 -0
  50. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/publishing/model_mixins.py +0 -0
  51. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/apps/authoring/publishing/models.py +0 -0
  52. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/contrib/__init__.py +0 -0
  53. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/contrib/media_server/__init__.py +0 -0
  54. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/contrib/media_server/apps.py +0 -0
  55. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/contrib/media_server/urls.py +0 -0
  56. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/contrib/media_server/views.py +0 -0
  57. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/lib/__init__.py +0 -0
  58. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/lib/admin_utils.py +0 -0
  59. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/lib/cache.py +0 -0
  60. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/lib/collations.py +0 -0
  61. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/lib/fields.py +0 -0
  62. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/lib/managers.py +0 -0
  63. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/lib/test_utils.py +0 -0
  64. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/lib/validators.py +0 -0
  65. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning/py.typed +0 -0
  66. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning.egg-info/SOURCES.txt +0 -0
  67. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning.egg-info/dependency_links.txt +0 -0
  68. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning.egg-info/not-zip-safe +0 -0
  69. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning.egg-info/requires.txt +3 -3
  70. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_learning.egg-info/top_level.txt +0 -0
  71. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/__init__.py +0 -0
  72. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/__init__.py +0 -0
  73. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/__init__.py +0 -0
  74. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/admin.py +0 -0
  75. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/api.py +0 -0
  76. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/apps.py +0 -0
  77. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/data.py +0 -0
  78. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/import_export/__init__.py +0 -0
  79. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/import_export/actions.py +0 -0
  80. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/import_export/exceptions.py +0 -0
  81. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/import_export/import_plan.py +0 -0
  82. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/import_export/parsers.py +0 -0
  83. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/import_export/tasks.py +0 -0
  84. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/import_export/template.csv +0 -0
  85. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/import_export/template.json +0 -0
  86. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0001_initial.py +0 -0
  87. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0001_squashed.py +0 -0
  88. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0002_auto_20230718_2026.py +0 -0
  89. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0003_auto_20230721_1238.py +0 -0
  90. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0004_auto_20230723_2001.py +0 -0
  91. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0005_language_taxonomy.py +0 -0
  92. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0006_alter_objecttag_unique_together.py +0 -0
  93. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0006_auto_20230802_1631.py +0 -0
  94. {openedx_learning-0.13.1 → openedx_learning-0.15.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.15.0}/openedx_tagging/core/tagging/migrations/0008_taxonomy_description_not_null.py +0 -0
  96. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0009_alter_objecttag_object_id.py +0 -0
  97. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0010_cleanups.py +0 -0
  98. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0011_remove_required.py +0 -0
  99. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0012_language_taxonomy.py +0 -0
  100. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0013_tag_parent_blank.py +0 -0
  101. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0014_minor_fixes.py +0 -0
  102. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0015_taxonomy_export_id.py +0 -0
  103. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0016_object_tag_export_id.py +0 -0
  104. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/0017_alter_tagimporttask_status.py +0 -0
  105. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/migrations/__init__.py +0 -0
  106. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/models/__init__.py +0 -0
  107. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/models/base.py +0 -0
  108. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/models/import_export.py +0 -0
  109. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/models/system_defined.py +0 -0
  110. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/models/utils.py +0 -0
  111. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/rest_api/__init__.py +0 -0
  112. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/rest_api/paginators.py +0 -0
  113. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/rest_api/urls.py +0 -0
  114. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/rest_api/utils.py +0 -0
  115. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/rest_api/v1/__init__.py +0 -0
  116. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/rest_api/v1/permissions.py +0 -0
  117. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/rest_api/v1/serializers.py +0 -0
  118. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/rest_api/v1/urls.py +0 -0
  119. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/rest_api/v1/views.py +0 -0
  120. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/rest_api/v1/views_import.py +0 -0
  121. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/rules.py +0 -0
  122. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/core/tagging/urls.py +0 -0
  123. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/openedx_tagging/py.typed +0 -0
  124. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/requirements/base.in +0 -0
  125. {openedx_learning-0.13.1 → openedx_learning-0.15.0}/setup.cfg +0 -0
  126. {openedx_learning-0.13.1 → openedx_learning-0.15.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.15.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
@@ -18,12 +18,12 @@ Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Requires-Python: >=3.11
20
20
  License-File: LICENSE.txt
21
- Requires-Dist: attrs
22
- Requires-Dist: Django<5.0
23
- Requires-Dist: djangorestframework<4.0
24
21
  Requires-Dist: edx-drf-extensions
25
22
  Requires-Dist: celery
23
+ Requires-Dist: attrs
24
+ Requires-Dist: Django<5.0
26
25
  Requires-Dist: rules<4.0
26
+ Requires-Dist: djangorestframework<4.0
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.15.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(
@@ -12,16 +12,18 @@ are stored in this app.
12
12
  """
13
13
  from __future__ import annotations
14
14
 
15
- from datetime import datetime
15
+ from datetime import datetime, timezone
16
16
  from enum import StrEnum, auto
17
17
  from logging import getLogger
18
18
  from pathlib import Path
19
19
  from uuid import UUID
20
20
 
21
+ from django.core.exceptions import ValidationError
21
22
  from django.db.models import Q, QuerySet
22
23
  from django.db.transaction import atomic
23
24
  from django.http.response import HttpResponse, HttpResponseNotFound
24
25
 
26
+ from ..collections.models import Collection, CollectionPublishableEntity
25
27
  from ..contents import api as contents_api
26
28
  from ..publishing import api as publishing_api
27
29
  from .models import Component, ComponentType, ComponentVersion, ComponentVersionContent
@@ -48,6 +50,7 @@ __all__ = [
48
50
  "look_up_component_version_content",
49
51
  "AssetError",
50
52
  "get_redirect_response_for_component_asset",
53
+ "set_collections",
51
54
  ]
52
55
 
53
56
 
@@ -269,7 +272,15 @@ def get_component_by_uuid(uuid: UUID) -> Component:
269
272
 
270
273
 
271
274
  def get_component_version_by_uuid(uuid: UUID) -> ComponentVersion:
272
- return ComponentVersion.objects.get(publishable_entity_version__uuid=uuid)
275
+ return (
276
+ ComponentVersion
277
+ .objects
278
+ .select_related(
279
+ "component",
280
+ "component__learning_package",
281
+ )
282
+ .get(publishable_entity_version__uuid=uuid)
283
+ )
273
284
 
274
285
 
275
286
  def component_exists_by_key(
@@ -395,7 +406,21 @@ def create_component_version_content(
395
406
  ) -> ComponentVersionContent:
396
407
  """
397
408
  Add a Content to the given ComponentVersion
409
+
410
+ We don't allow keys that would be absolute paths, e.g. ones that start with
411
+ '/'. Storing these causes headaches with building relative paths and because
412
+ of mismatches with things that expect a leading slash and those that don't.
413
+ So for safety and consistency, we strip off leading slashes and emit a
414
+ warning when we do.
398
415
  """
416
+ if key.startswith('/'):
417
+ logger.warning(
418
+ "Absolute paths are not supported: "
419
+ f"removed leading '/' from ComponentVersion {component_version_id} "
420
+ f"content key: {repr(key)} (content_id: {content_id})"
421
+ )
422
+ key = key.lstrip('/')
423
+
399
424
  cvrc, _created = ComponentVersionContent.objects.get_or_create(
400
425
  component_version_id=component_version_id,
401
426
  content_id=content_id,
@@ -510,7 +535,12 @@ def get_redirect_response_for_component_asset(
510
535
 
511
536
  # Check: Does the ComponentVersion exist?
512
537
  try:
513
- component_version = get_component_version_by_uuid(component_version_uuid)
538
+ component_version = (
539
+ ComponentVersion
540
+ .objects
541
+ .select_related("component", "component__learning_package")
542
+ .get(publishable_entity_version__uuid=component_version_uuid)
543
+ )
514
544
  except ComponentVersion.DoesNotExist:
515
545
  # No need to add headers here, because no ComponentVersion was found.
516
546
  logger.error(f"Asset Not Found: No ComponentVersion with UUID {component_version_uuid}")
@@ -567,13 +597,63 @@ def get_redirect_response_for_component_asset(
567
597
  # At this point, we know that there is valid Content that we want to send.
568
598
  # This adds Content-level headers, like the hash/etag and content type.
569
599
  info_headers.update(contents_api.get_content_info_headers(content))
570
- stored_file_path = content.file_path()
571
600
 
572
601
  # Recompute redirect headers (reminder: this should never be cached).
573
- redirect_headers = contents_api.get_redirect_headers(stored_file_path, public)
602
+ redirect_headers = contents_api.get_redirect_headers(content.path, public)
574
603
  logger.info(
575
604
  "Asset redirect (uncached metadata): "
576
605
  f"{component_version_uuid}/{asset_path} -> {redirect_headers}"
577
606
  )
578
607
 
579
608
  return HttpResponse(headers={**info_headers, **redirect_headers})
609
+
610
+
611
+ def set_collections(
612
+ learning_package_id: int,
613
+ component: Component,
614
+ collection_qset: QuerySet[Collection],
615
+ created_by: int | None = None,
616
+ ) -> set[Collection]:
617
+ """
618
+ Set collections for a given component.
619
+
620
+ These Collections must belong to the same LearningPackage as the Component, or a ValidationError will be raised.
621
+
622
+ Modified date of all collections related to component is updated.
623
+
624
+ Returns the updated collections.
625
+ """
626
+ # Disallow adding entities outside the collection's learning package
627
+ invalid_collection = collection_qset.exclude(learning_package_id=learning_package_id).first()
628
+ if invalid_collection:
629
+ raise ValidationError(
630
+ f"Cannot add collection {invalid_collection.pk} in learning package "
631
+ f"{invalid_collection.learning_package_id} to component {component} in "
632
+ f"learning package {learning_package_id}."
633
+ )
634
+ current_relations = CollectionPublishableEntity.objects.filter(
635
+ entity=component.publishable_entity
636
+ ).select_related('collection')
637
+ # Clear other collections for given component and add only new collections from collection_qset
638
+ removed_collections = set(
639
+ r.collection for r in current_relations.exclude(collection__in=collection_qset)
640
+ )
641
+ new_collections = set(collection_qset.exclude(
642
+ id__in=current_relations.values_list('collection', flat=True)
643
+ ))
644
+ # Use `remove` instead of `CollectionPublishableEntity.delete()` to trigger m2m_changed signal which will handle
645
+ # updating component index.
646
+ component.publishable_entity.collections.remove(*removed_collections)
647
+ component.publishable_entity.collections.add(
648
+ *new_collections,
649
+ through_defaults={"created_by_id": created_by},
650
+ )
651
+ # Update modified date via update to avoid triggering post_save signal for collections
652
+ # The signal triggers index update for each collection synchronously which will be very slow in this case.
653
+ # Instead trigger the index update in the caller function asynchronously.
654
+ affected_collection = removed_collections | new_collections
655
+ Collection.objects.filter(
656
+ id__in=[collection.id for collection in affected_collection]
657
+ ).update(modified=datetime.now(tz=timezone.utc))
658
+
659
+ return affected_collection
@@ -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.15.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
@@ -18,12 +18,12 @@ Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Requires-Python: >=3.11
20
20
  License-File: LICENSE.txt
21
- Requires-Dist: attrs
22
- Requires-Dist: Django<5.0
23
- Requires-Dist: djangorestframework<4.0
24
21
  Requires-Dist: edx-drf-extensions
25
22
  Requires-Dist: celery
23
+ Requires-Dist: attrs
24
+ Requires-Dist: Django<5.0
26
25
  Requires-Dist: rules<4.0
26
+ Requires-Dist: djangorestframework<4.0
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
- attrs
2
- Django<5.0
3
- djangorestframework<4.0
4
1
  edx-drf-extensions
5
2
  celery
3
+ attrs
4
+ Django<5.0
6
5
  rules<4.0
6
+ djangorestframework<4.0