openedx-learning 0.5.0__tar.gz → 0.5.1__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 (108) hide show
  1. {openedx-learning-0.5.0/openedx_learning.egg-info → openedx-learning-0.5.1}/PKG-INFO +1 -1
  2. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/__init__.py +1 -1
  3. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/components/admin.py +3 -4
  4. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/components/api.py +30 -18
  5. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/components/migrations/0001_initial.py +22 -7
  6. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/components/models.py +62 -30
  7. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/contents/api.py +4 -5
  8. {openedx-learning-0.5.0 → openedx-learning-0.5.1/openedx_learning.egg-info}/PKG-INFO +1 -1
  9. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning.egg-info/SOURCES.txt +1 -0
  10. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/api.py +8 -0
  11. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/api.py +1 -1
  12. openedx-learning-0.5.1/openedx_tagging/core/tagging/migrations/0015_taxonomy_export_id.py +38 -0
  13. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/models/base.py +23 -0
  14. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/v1/serializers.py +3 -0
  15. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/v1/views.py +19 -2
  16. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/CHANGELOG.rst +0 -0
  17. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/LICENSE.txt +0 -0
  18. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/MANIFEST.in +0 -0
  19. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/README.rst +0 -0
  20. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/contrib/__init__.py +0 -0
  21. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/contrib/media_server/__init__.py +0 -0
  22. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/contrib/media_server/apps.py +0 -0
  23. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/contrib/media_server/urls.py +0 -0
  24. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/contrib/media_server/views.py +0 -0
  25. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/__init__.py +0 -0
  26. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/components/__init__.py +0 -0
  27. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/components/apps.py +0 -0
  28. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/components/migrations/__init__.py +0 -0
  29. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/contents/__init__.py +0 -0
  30. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/contents/admin.py +0 -0
  31. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/contents/apps.py +0 -0
  32. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/contents/migrations/0001_initial.py +0 -0
  33. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/contents/migrations/__init__.py +0 -0
  34. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/contents/models.py +0 -0
  35. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/publishing/__init__.py +0 -0
  36. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/publishing/admin.py +0 -0
  37. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/publishing/api.py +0 -0
  38. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/publishing/apps.py +0 -0
  39. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/publishing/migrations/0001_initial.py +0 -0
  40. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/publishing/migrations/__init__.py +0 -0
  41. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/publishing/model_mixins.py +0 -0
  42. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/core/publishing/models.py +0 -0
  43. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/lib/__init__.py +0 -0
  44. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/lib/admin_utils.py +0 -0
  45. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/lib/cache.py +0 -0
  46. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/lib/collations.py +0 -0
  47. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/lib/fields.py +0 -0
  48. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/lib/managers.py +0 -0
  49. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/lib/test_utils.py +0 -0
  50. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/lib/validators.py +0 -0
  51. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/rest_api/__init__.py +0 -0
  52. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/rest_api/apps.py +0 -0
  53. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/rest_api/urls.py +0 -0
  54. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/rest_api/v1/__init__.py +0 -0
  55. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/rest_api/v1/components.py +0 -0
  56. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning/rest_api/v1/urls.py +0 -0
  57. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning.egg-info/dependency_links.txt +0 -0
  58. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning.egg-info/not-zip-safe +0 -0
  59. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning.egg-info/requires.txt +3 -3
  60. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_learning.egg-info/top_level.txt +0 -0
  61. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/__init__.py +0 -0
  62. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/__init__.py +0 -0
  63. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/__init__.py +0 -0
  64. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/admin.py +0 -0
  65. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/apps.py +0 -0
  66. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/data.py +0 -0
  67. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/__init__.py +0 -0
  68. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/actions.py +0 -0
  69. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/exceptions.py +0 -0
  70. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/import_plan.py +0 -0
  71. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/parsers.py +0 -0
  72. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/tasks.py +0 -0
  73. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/template.csv +0 -0
  74. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/template.json +0 -0
  75. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0001_initial.py +0 -0
  76. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0001_squashed.py +0 -0
  77. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0002_auto_20230718_2026.py +0 -0
  78. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0003_auto_20230721_1238.py +0 -0
  79. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0004_auto_20230723_2001.py +0 -0
  80. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0005_language_taxonomy.py +0 -0
  81. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0006_alter_objecttag_unique_together.py +0 -0
  82. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0006_auto_20230802_1631.py +0 -0
  83. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0007_tag_import_task_log_null_fix.py +0 -0
  84. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0008_taxonomy_description_not_null.py +0 -0
  85. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0009_alter_objecttag_object_id.py +0 -0
  86. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0010_cleanups.py +0 -0
  87. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0011_remove_required.py +0 -0
  88. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0012_language_taxonomy.py +0 -0
  89. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0013_tag_parent_blank.py +0 -0
  90. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0014_minor_fixes.py +0 -0
  91. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/__init__.py +0 -0
  92. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/models/__init__.py +0 -0
  93. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/models/import_export.py +0 -0
  94. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/models/system_defined.py +0 -0
  95. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/models/utils.py +0 -0
  96. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/__init__.py +0 -0
  97. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/paginators.py +0 -0
  98. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/urls.py +0 -0
  99. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/utils.py +0 -0
  100. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/v1/__init__.py +0 -0
  101. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/v1/permissions.py +0 -0
  102. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/v1/urls.py +0 -0
  103. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/v1/views_import.py +0 -0
  104. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rules.py +0 -0
  105. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/urls.py +0 -0
  106. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/requirements/base.in +0 -0
  107. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/setup.cfg +0 -0
  108. {openedx-learning-0.5.0 → openedx-learning-0.5.1}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 1.2
2
2
  Name: openedx-learning
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: An experiment.
5
5
  Home-page: https://github.com/openedx/openedx-learning
6
6
  Author: David Ormsbee
@@ -1,4 +1,4 @@
1
1
  """
2
2
  Open edX Learning ("Learning Core").
3
3
  """
4
- __version__ = "0.5.0"
4
+ __version__ = "0.5.1"
@@ -35,16 +35,15 @@ class ComponentAdmin(ReadOnlyModelAdmin):
35
35
  """
36
36
  Django admin configuration for Component
37
37
  """
38
- list_display = ("key", "uuid", "namespace", "type", "created")
38
+ list_display = ("key", "uuid", "component_type", "created")
39
39
  readonly_fields = [
40
40
  "learning_package",
41
41
  "uuid",
42
- "namespace",
43
- "type",
42
+ "component_type",
44
43
  "key",
45
44
  "created",
46
45
  ]
47
- list_filter = ("type", "learning_package")
46
+ list_filter = ("component_type", "learning_package")
48
47
  search_fields = ["publishable_entity__uuid", "publishable_entity__key"]
49
48
  inlines = [ComponentVersionInline]
50
49
 
@@ -18,15 +18,28 @@ from pathlib import Path
18
18
  from django.db.models import Q, QuerySet
19
19
  from django.db.transaction import atomic
20
20
 
21
+ from ...lib.cache import lru_cache
21
22
  from ..publishing import api as publishing_api
22
- from .models import Component, ComponentVersion, ComponentVersionRawContent
23
+ from .models import Component, ComponentType, ComponentVersion, ComponentVersionRawContent
24
+
25
+
26
+ @lru_cache(maxsize=128)
27
+ def get_or_create_component_type_id(namespace: str, name: str) -> int:
28
+ """
29
+ Get the ID of a ComponentType, and create if missing.
30
+ """
31
+ component_type, _created = ComponentType.objects.get_or_create(
32
+ namespace=namespace,
33
+ name=name,
34
+ )
35
+ return component_type.id
23
36
 
24
37
 
25
38
  def create_component(
26
39
  learning_package_id: int,
27
40
  /,
28
41
  namespace: str,
29
- type: str, # pylint: disable=redefined-builtin
42
+ type_name: str,
30
43
  local_key: str,
31
44
  created: datetime,
32
45
  created_by: int | None,
@@ -34,7 +47,7 @@ def create_component(
34
47
  """
35
48
  Create a new Component (an entity like a Problem or Video)
36
49
  """
37
- key = f"{namespace}:{type}@{local_key}"
50
+ key = f"{namespace}:{type_name}@{local_key}"
38
51
  with atomic():
39
52
  publishable_entity = publishing_api.create_publishable_entity(
40
53
  learning_package_id, key, created, created_by
@@ -42,8 +55,7 @@ def create_component(
42
55
  component = Component.objects.create(
43
56
  publishable_entity=publishable_entity,
44
57
  learning_package_id=learning_package_id,
45
- namespace=namespace,
46
- type=type,
58
+ component_type_id=get_or_create_component_type_id(namespace, type_name),
47
59
  local_key=local_key,
48
60
  )
49
61
  return component
@@ -163,7 +175,7 @@ def create_component_and_version(
163
175
  learning_package_id: int,
164
176
  /,
165
177
  namespace: str,
166
- type: str, # pylint: disable=redefined-builtin
178
+ type_name: str,
167
179
  local_key: str,
168
180
  title: str,
169
181
  created: datetime,
@@ -174,7 +186,7 @@ def create_component_and_version(
174
186
  """
175
187
  with atomic():
176
188
  component = create_component(
177
- learning_package_id, namespace, type, local_key, created, created_by
189
+ learning_package_id, namespace, type_name, local_key, created, created_by
178
190
  )
179
191
  component_version = create_component_version(
180
192
  component.pk,
@@ -199,7 +211,7 @@ def get_component_by_key(
199
211
  learning_package_id: int,
200
212
  /,
201
213
  namespace: str,
202
- type: str, # pylint: disable=redefined-builtin
214
+ type_name: str,
203
215
  local_key: str,
204
216
  ) -> Component:
205
217
  """
@@ -208,8 +220,8 @@ def get_component_by_key(
208
220
  return Component.with_publishing_relations \
209
221
  .get(
210
222
  learning_package_id=learning_package_id,
211
- namespace=namespace,
212
- type=type,
223
+ component_type__namespace=namespace,
224
+ component_type__name=type_name,
213
225
  local_key=local_key,
214
226
  )
215
227
 
@@ -218,7 +230,7 @@ def component_exists_by_key(
218
230
  learning_package_id: int,
219
231
  /,
220
232
  namespace: str,
221
- type: str, # pylint: disable=redefined-builtin
233
+ type_name: str,
222
234
  local_key: str
223
235
  ) -> bool:
224
236
  """
@@ -228,10 +240,10 @@ def component_exists_by_key(
228
240
  no current Draft version for it), or if it's been unpublished.
229
241
  """
230
242
  try:
231
- _component = Component.objects.only('pk').get(
243
+ _component = Component.objects.only('pk', 'component_type').get(
232
244
  learning_package_id=learning_package_id,
233
- namespace=namespace,
234
- type=type,
245
+ component_type__namespace=namespace,
246
+ component_type__name=type_name,
235
247
  local_key=local_key,
236
248
  )
237
249
  return True
@@ -245,7 +257,7 @@ def get_components(
245
257
  draft: bool | None = None,
246
258
  published: bool | None = None,
247
259
  namespace: str | None = None,
248
- types: list[str] | None = None,
260
+ type_names: list[str] | None = None,
249
261
  draft_title: str | None = None,
250
262
  published_title: str | None = None,
251
263
  ) -> QuerySet[Component]:
@@ -265,9 +277,9 @@ def get_components(
265
277
  if published is not None:
266
278
  qset = qset.filter(publishable_entity__published__version__isnull=not published)
267
279
  if namespace is not None:
268
- qset = qset.filter(namespace=namespace)
269
- if types is not None:
270
- qset = qset.filter(type__in=types)
280
+ qset = qset.filter(component_type__namespace=namespace)
281
+ if type_names is not None:
282
+ qset = qset.filter(component_type__name__in=type_names)
271
283
  if draft_title is not None:
272
284
  qset = qset.filter(
273
285
  publishable_entity__draft__version__title__icontains=draft_title
@@ -1,4 +1,4 @@
1
- # Generated by Django 3.2.23 on 2024-01-22 00:38
1
+ # Generated by Django 3.2.23 on 2024-01-31 05:34
2
2
 
3
3
  import uuid
4
4
 
@@ -13,8 +13,8 @@ class Migration(migrations.Migration):
13
13
  initial = True
14
14
 
15
15
  dependencies = [
16
- ('oel_contents', '0001_initial'),
17
16
  ('oel_publishing', '0001_initial'),
17
+ ('oel_contents', '0001_initial'),
18
18
  ]
19
19
 
20
20
  operations = [
@@ -22,16 +22,21 @@ class Migration(migrations.Migration):
22
22
  name='Component',
23
23
  fields=[
24
24
  ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity')),
25
- ('namespace', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=100)),
26
- ('type', openedx_learning.lib.fields.MultiCollationCharField(blank=True, db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=100)),
27
25
  ('local_key', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=500)),
28
- ('learning_package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.learningpackage')),
29
26
  ],
30
27
  options={
31
28
  'verbose_name': 'Component',
32
29
  'verbose_name_plural': 'Components',
33
30
  },
34
31
  ),
32
+ migrations.CreateModel(
33
+ name='ComponentType',
34
+ fields=[
35
+ ('id', models.AutoField(primary_key=True, serialize=False)),
36
+ ('namespace', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=100)),
37
+ ('name', openedx_learning.lib.fields.MultiCollationCharField(blank=True, db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=100)),
38
+ ],
39
+ ),
35
40
  migrations.CreateModel(
36
41
  name='ComponentVersion',
37
42
  fields=[
@@ -59,6 +64,16 @@ class Migration(migrations.Migration):
59
64
  name='raw_contents',
60
65
  field=models.ManyToManyField(related_name='component_versions', through='oel_components.ComponentVersionRawContent', to='oel_contents.RawContent'),
61
66
  ),
67
+ migrations.AddField(
68
+ model_name='component',
69
+ name='component_type',
70
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='oel_components.componenttype'),
71
+ ),
72
+ migrations.AddField(
73
+ model_name='component',
74
+ name='learning_package',
75
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.learningpackage'),
76
+ ),
62
77
  migrations.AddIndex(
63
78
  model_name='componentversionrawcontent',
64
79
  index=models.Index(fields=['raw_content', 'component_version'], name='oel_cvrawcontent_c_cv'),
@@ -73,10 +88,10 @@ class Migration(migrations.Migration):
73
88
  ),
74
89
  migrations.AddIndex(
75
90
  model_name='component',
76
- index=models.Index(fields=['learning_package', 'namespace', 'type', 'local_key'], name='oel_component_idx_lc_ns_t_lk'),
91
+ index=models.Index(fields=['component_type', 'local_key'], name='oel_component_idx_ct_lk'),
77
92
  ),
78
93
  migrations.AddConstraint(
79
94
  model_name='component',
80
- constraint=models.UniqueConstraint(fields=('learning_package', 'namespace', 'type', 'local_key'), name='oel_component_uniq_lc_ns_t_lk'),
95
+ constraint=models.UniqueConstraint(fields=('learning_package', 'component_type', 'local_key'), name='oel_component_uniq_lc_ct_lk'),
81
96
  ),
82
97
  ]
@@ -28,6 +28,45 @@ from ..publishing.model_mixins import PublishableEntityMixin, PublishableEntityV
28
28
  from ..publishing.models import LearningPackage
29
29
 
30
30
 
31
+ class ComponentType(models.Model):
32
+ """
33
+ Normalized representation of a type of Component.
34
+
35
+ The only namespace being used initially will be 'xblock.v1', but we will
36
+ probably add a few others over time, such as a component type to represent
37
+ packages of files for things like Files and Uploads or python_lib.zip files.
38
+
39
+ Make a ForeignKey against this table if you have to set policy based on the
40
+ type of Components–e.g. marking certain types of XBlocks as approved vs.
41
+ experimental for use in libraries.
42
+ """
43
+ id = models.AutoField(primary_key=True)
44
+
45
+ # namespace and name work together to help figure out what Component needs
46
+ # to handle this data. A namespace is *required*. The namespace for XBlocks
47
+ # is "xblock.v1" (to match the setup.py entrypoint naming scheme).
48
+ namespace = case_sensitive_char_field(max_length=100, blank=False)
49
+
50
+ # name is a way to help sub-divide namespace if that's convenient. This
51
+ # field cannot be null, but it can be blank if it's not necessary. For an
52
+ # XBlock, this corresponds to tag, e.g. "video". It's also the block_type in
53
+ # the UsageKey.
54
+ name = case_sensitive_char_field(max_length=100, blank=True)
55
+
56
+ constraints = [
57
+ models.UniqueConstraint(
58
+ fields=[
59
+ "namespace",
60
+ "name",
61
+ ],
62
+ name="oel_component_type_uniq_ns_n",
63
+ ),
64
+ ]
65
+
66
+ def __str__(self):
67
+ return f"{self.namespace}:{self.name}"
68
+
69
+
31
70
  class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
32
71
  """
33
72
  This represents any Component that has ever existed in a LearningPackage.
@@ -63,7 +102,7 @@ class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
63
102
  -----------------
64
103
 
65
104
  The ``key`` field on Component's ``publishable_entity`` is dervied from the
66
- ``(namespace, type, local_key)`` fields in this model. We don't support
105
+ ``component_type`` and ``local_key`` fields in this model. We don't support
67
106
  changing the keys yet, but if we do, those values need to be kept in sync.
68
107
 
69
108
  How build on this model
@@ -75,9 +114,12 @@ class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
75
114
  # Tell mypy what type our objects manager has.
76
115
  # It's actually PublishableEntityMixinManager, but that has the exact same
77
116
  # interface as the base manager class.
78
- objects: models.Manager[Component]
117
+ objects: models.Manager[Component] = WithRelationsManager(
118
+ 'component_type'
119
+ )
79
120
 
80
- with_publishing_relations = WithRelationsManager(
121
+ with_publishing_relations: models.Manager[Component] = WithRelationsManager(
122
+ 'component_type',
81
123
  'publishable_entity',
82
124
  'publishable_entity__draft__version',
83
125
  'publishable_entity__draft__version__componentversion',
@@ -93,53 +135,43 @@ class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
93
135
  # columns from different tables).
94
136
  learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE)
95
137
 
96
- # namespace and type work together to help figure out what Component needs
97
- # to handle this data. A namespace is *required*. The namespace for XBlocks
98
- # is "xblock.v1" (to match the setup.py entrypoint naming scheme).
99
- namespace = case_sensitive_char_field(max_length=100, blank=False)
138
+ # What kind of Component are we? This will usually represent a specific
139
+ # XBlock block_type, but we want it to be more flexible in the long term.
140
+ component_type = models.ForeignKey(ComponentType, on_delete=models.PROTECT)
100
141
 
101
- # type is a way to help sub-divide namespace if that's convenient. This
102
- # field cannot be null, but it can be blank if it's not necessary. For an
103
- # XBlock, type corresponds to tag, e.g. "video". It's also the block_type in
104
- # the UsageKey.
105
- type = case_sensitive_char_field(max_length=100, blank=True)
106
-
107
- # local_key is an identifier that is local to the (namespace, type). The
108
- # publishable.key should be calculated as a combination of (namespace, type,
109
- # local_key).
142
+ # local_key is an identifier that is local to the learning_package and
143
+ # component_type. The publishable.key should be calculated as a
144
+ # combination of component_type and local_key.
110
145
  local_key = key_field()
111
146
 
112
147
  class Meta:
113
148
  constraints = [
114
- # The combination of (namespace, type, local_key) is unique within
149
+ # The combination of (component_type, local_key) is unique within
115
150
  # a given LearningPackage. Note that this means it is possible to
116
- # have two Components that have the exact same local_key. An XBlock
117
- # would be modeled as namespace="xblock.v1" with the type as the
118
- # block_type, so the local_key would only be the block_id (the
119
- # very last part of the UsageKey).
151
+ # have two Components in the same LearningPackage to have the same
152
+ # local_key if the component_types are different. So for example,
153
+ # you could have a ProblemBlock and VideoBlock that both have the
154
+ # local_key "week_1".
120
155
  models.UniqueConstraint(
121
156
  fields=[
122
157
  "learning_package",
123
- "namespace",
124
- "type",
158
+ "component_type",
125
159
  "local_key",
126
160
  ],
127
- name="oel_component_uniq_lc_ns_t_lk",
161
+ name="oel_component_uniq_lc_ct_lk",
128
162
  ),
129
163
  ]
130
164
  indexes = [
131
- # Global Namespace/Type/Local-Key Index:
165
+ # Global Component-Type/Local-Key Index:
132
166
  # * Search by the different Components fields across all Learning
133
167
  # Packages on the site. This would be a support-oriented tool
134
168
  # from Django Admin.
135
169
  models.Index(
136
170
  fields=[
137
- "learning_package",
138
- "namespace",
139
- "type",
171
+ "component_type",
140
172
  "local_key",
141
173
  ],
142
- name="oel_component_idx_lc_ns_t_lk",
174
+ name="oel_component_idx_ct_lk",
143
175
  ),
144
176
  ]
145
177
 
@@ -148,7 +180,7 @@ class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
148
180
  verbose_name_plural = "Components"
149
181
 
150
182
  def __str__(self):
151
- return f"{self.namespace}:{self.type}:{self.local_key}"
183
+ return f"{self.component_type.namespace}:{self.component_type.name}:{self.local_key}"
152
184
 
153
185
 
154
186
  class ComponentVersion(PublishableEntityVersionMixin):
@@ -12,9 +12,8 @@ from datetime import datetime
12
12
  from django.core.files.base import ContentFile
13
13
  from django.db.transaction import atomic
14
14
 
15
- from openedx_learning.lib.cache import lru_cache
16
- from openedx_learning.lib.fields import create_hash_digest
17
-
15
+ from ...lib.cache import lru_cache
16
+ from ...lib.fields import create_hash_digest
18
17
  from .models import MediaType, RawContent, TextContent
19
18
 
20
19
 
@@ -33,7 +32,7 @@ def create_raw_content(
33
32
 
34
33
  raw_content = RawContent.objects.create(
35
34
  learning_package_id=learning_package_id,
36
- media_type_id=get_media_type_id(mime_type),
35
+ media_type_id=get_or_create_media_type_id(mime_type),
37
36
  hash_digest=hash_digest,
38
37
  size=len(data_bytes),
39
38
  created=created,
@@ -58,7 +57,7 @@ def create_text_from_raw_content(raw_content: RawContent, encoding="utf-8-sig")
58
57
 
59
58
 
60
59
  @lru_cache(maxsize=128)
61
- def get_media_type_id(mime_type: str) -> int:
60
+ def get_or_create_media_type_id(mime_type: str) -> int:
62
61
  """
63
62
  Return the MediaType.id for the desired mime_type string.
64
63
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 1.2
2
2
  Name: openedx-learning
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: An experiment.
5
5
  Home-page: https://github.com/openedx/openedx-learning
6
6
  Author: David Ormsbee
@@ -87,6 +87,7 @@ openedx_tagging/core/tagging/migrations/0011_remove_required.py
87
87
  openedx_tagging/core/tagging/migrations/0012_language_taxonomy.py
88
88
  openedx_tagging/core/tagging/migrations/0013_tag_parent_blank.py
89
89
  openedx_tagging/core/tagging/migrations/0014_minor_fixes.py
90
+ openedx_tagging/core/tagging/migrations/0015_taxonomy_export_id.py
90
91
  openedx_tagging/core/tagging/migrations/__init__.py
91
92
  openedx_tagging/core/tagging/models/__init__.py
92
93
  openedx_tagging/core/tagging/models/base.py
@@ -17,6 +17,7 @@ from typing import Any
17
17
  from django.db import models, transaction
18
18
  from django.db.models import F, QuerySet, Value
19
19
  from django.db.models.functions import Coalesce, Concat, Lower
20
+ from django.utils.text import slugify
20
21
  from django.utils.translation import gettext as _
21
22
 
22
23
  from .data import TagDataQuerySet
@@ -34,19 +35,26 @@ def create_taxonomy(
34
35
  allow_multiple=True,
35
36
  allow_free_text=False,
36
37
  taxonomy_class: type[Taxonomy] | None = None,
38
+ export_id: str | None = None,
37
39
  ) -> Taxonomy:
38
40
  """
39
41
  Creates, saves, and returns a new Taxonomy with the given attributes.
40
42
  """
43
+ if not export_id:
44
+ export_id = f"{Taxonomy.objects.count() + 1}-{slugify(name, allow_unicode=True)}"
45
+
41
46
  taxonomy = Taxonomy(
42
47
  name=name,
43
48
  description=description or "",
44
49
  enabled=enabled,
45
50
  allow_multiple=allow_multiple,
46
51
  allow_free_text=allow_free_text,
52
+ export_id=export_id,
47
53
  )
48
54
  if taxonomy_class:
49
55
  taxonomy.taxonomy_class = taxonomy_class
56
+
57
+ taxonomy.full_clean()
50
58
  taxonomy.save()
51
59
  return taxonomy.cast()
52
60
 
@@ -121,7 +121,7 @@ def import_tags(
121
121
  task.end_success()
122
122
 
123
123
  return True, task, tag_import_plan
124
- except Exception as exception: # pylint: disable=broad-exception-caught
124
+ except Exception as exception:
125
125
  # Log any exception
126
126
  task.log_exception(exception)
127
127
  return False, task, None
@@ -0,0 +1,38 @@
1
+ # Generated by Django 3.2.22 on 2024-01-25 14:20
2
+
3
+ from django.db import migrations, models
4
+ from django.utils.text import slugify
5
+
6
+
7
+ def migrate_export_id(apps, schema_editor):
8
+ Taxonomy = apps.get_model("oel_tagging", "Taxonomy")
9
+ for taxonomy in Taxonomy.objects.all():
10
+ # Adds the id of the taxonomy to avoid duplicates
11
+ taxonomy.export_id = f"{taxonomy.id}-{slugify(taxonomy.name, allow_unicode=True)}"
12
+ taxonomy.save(update_fields=["export_id"])
13
+
14
+ def reverse(app, schema_editor):
15
+ pass
16
+
17
+ class Migration(migrations.Migration):
18
+
19
+ dependencies = [
20
+ ('oel_tagging', '0014_minor_fixes'),
21
+ ]
22
+
23
+ operations = [
24
+ # Create the field allowing null
25
+ migrations.AddField(
26
+ model_name='taxonomy',
27
+ name='export_id',
28
+ field=models.CharField(help_text="User-facing ID that is used on import/export. Should only contain alphanumeric characters or '_' '-' '.'", max_length=255, null=True, unique=True),
29
+ ),
30
+ # Fill the field for created taxonomies
31
+ migrations.RunPython(migrate_export_id, reverse),
32
+ # Alter the field to not allowing null
33
+ migrations.AlterField(
34
+ model_name='taxonomy',
35
+ name='export_id',
36
+ field=models.CharField(help_text="User-facing ID that is used on import/export. Should only contain alphanumeric characters or '_' '-' '.'", max_length=255, null=False, unique=True),
37
+ ),
38
+ ]
@@ -4,6 +4,7 @@ Tagging app base data models
4
4
  from __future__ import annotations
5
5
 
6
6
  import logging
7
+ import re
7
8
  from typing import List
8
9
 
9
10
  from django.core.exceptions import ValidationError
@@ -228,6 +229,19 @@ class Taxonomy(models.Model):
228
229
  "Indicates whether this taxonomy should be visible to object authors."
229
230
  ),
230
231
  )
232
+ # External ID that should only be used on import/export.
233
+ # NOT use for any other purposes, you can use the numeric ID of the model instead;
234
+ # this id is editable.
235
+ export_id = models.CharField(
236
+ null=False,
237
+ blank=False,
238
+ max_length=255,
239
+ help_text=_(
240
+ "User-facing ID that is used on import/export."
241
+ " Should only contain alphanumeric characters or '_' '-' '.'"
242
+ ),
243
+ unique=True,
244
+ )
231
245
  _taxonomy_class = models.CharField(
232
246
  null=True,
233
247
  max_length=255,
@@ -300,6 +314,14 @@ class Taxonomy(models.Model):
300
314
  """
301
315
  return False
302
316
 
317
+ def clean(self):
318
+ super().clean()
319
+
320
+ if not re.match(r'^[\w\-.]+$', self.export_id):
321
+ raise ValidationError(
322
+ "The export_id should only contain alphanumeric characters or '_' '-' '.'"
323
+ )
324
+
303
325
  def cast(self):
304
326
  """
305
327
  Returns the current Taxonomy instance cast into its taxonomy_class.
@@ -336,6 +358,7 @@ class Taxonomy(models.Model):
336
358
  self.allow_multiple = taxonomy.allow_multiple
337
359
  self.allow_free_text = taxonomy.allow_free_text
338
360
  self.visible_to_authors = taxonomy.visible_to_authors
361
+ self.export_id = taxonomy.export_id
339
362
  self._taxonomy_class = taxonomy._taxonomy_class # pylint: disable=protected-access
340
363
  return self
341
364
 
@@ -72,6 +72,7 @@ class TaxonomySerializer(UserPermissionsSerializerMixin, serializers.ModelSerial
72
72
  can_change_taxonomy = serializers.SerializerMethodField(method_name='get_can_change')
73
73
  can_delete_taxonomy = serializers.SerializerMethodField(method_name='get_can_delete')
74
74
  can_tag_object = serializers.SerializerMethodField()
75
+ export_id = serializers.CharField(required=False)
75
76
 
76
77
  class Meta:
77
78
  model = Taxonomy
@@ -88,6 +89,7 @@ class TaxonomySerializer(UserPermissionsSerializerMixin, serializers.ModelSerial
88
89
  "can_change_taxonomy",
89
90
  "can_delete_taxonomy",
90
91
  "can_tag_object",
92
+ "export_id",
91
93
  ]
92
94
 
93
95
  def to_representation(self, instance):
@@ -332,6 +334,7 @@ class TaxonomyImportNewBodySerializer(TaxonomyImportBodySerializer): # pylint:
332
334
  """
333
335
  taxonomy_name = serializers.CharField(required=True)
334
336
  taxonomy_description = serializers.CharField(default="")
337
+ taxonomy_export_id = serializers.CharField(required=True)
335
338
 
336
339
 
337
340
  class TagImportTaskSerializer(serializers.ModelSerializer):
@@ -3,6 +3,7 @@ Tagging API Views
3
3
  """
4
4
  from __future__ import annotations
5
5
 
6
+ from django.core import exceptions
6
7
  from django.db import models
7
8
  from django.http import Http404, HttpResponse
8
9
  from rest_framework import mixins, status
@@ -57,6 +58,11 @@ class TaxonomyView(ModelViewSet):
57
58
  """
58
59
  View to list, create, retrieve, update, delete, export or import Taxonomies.
59
60
 
61
+ TODO: We need to add a perform_update and call the api update function when is created.
62
+ This is because it is necessary to call the model validations. (`full_clean()`).
63
+ Currently those validations are not run, which means we could update `export_id` with any value
64
+ through this api.
65
+
60
66
  **List Query Parameters**
61
67
  * enabled (optional) - Filter by enabled status. Valid values: true,
62
68
  false, 1, 0, "true", "false", "1"
@@ -87,6 +93,8 @@ class TaxonomyView(ModelViewSet):
87
93
  **Create Parameters**
88
94
  * name (required): User-facing label used when applying tags from this
89
95
  taxonomy to Open edX objects.
96
+ * export_id (required): User-facing ID that is used on import/export.
97
+ Should only contain alphanumeric characters or '_' '-' '.'.
90
98
  * description (optional): Provides extra information for the user when
91
99
  applying tags from this taxonomy to an object.
92
100
  * enabled (optional): Only enabled taxonomies will be shown to authors
@@ -101,6 +109,7 @@ class TaxonomyView(ModelViewSet):
101
109
  POST api/tagging/v1/taxonomy - Create a taxonomy
102
110
  {
103
111
  "name": "Taxonomy Name",
112
+ "export_id": "taxonomy_export_id",
104
113
  "description": "This is a description",
105
114
  "enabled": True,
106
115
  "allow_multiple": True,
@@ -117,6 +126,8 @@ class TaxonomyView(ModelViewSet):
117
126
  **Update Request Body**
118
127
  * name (optional): User-facing label used when applying tags from this
119
128
  taxonomy to Open edX objects.
129
+ * export_id (optional): User-facing ID that is used on import/export.
130
+ Should only contain alphanumeric characters or '_' '-' '.'.
120
131
  * description (optional): Provides extra information for the user when
121
132
  applying tags from this taxonomy to an object.
122
133
  * enabled (optional): Only enabled taxonomies will be shown to authors.
@@ -129,6 +140,7 @@ class TaxonomyView(ModelViewSet):
129
140
  PUT api/tagging/v1/taxonomy/:pk - Update a taxonomy
130
141
  {
131
142
  "name": "Taxonomy New Name",
143
+ "export_id": "taxonomy_new_name",
132
144
  "description": "This is a new description",
133
145
  "enabled": False,
134
146
  "allow_multiple": False,
@@ -174,6 +186,7 @@ class TaxonomyView(ModelViewSet):
174
186
  POST /tagging/rest_api/v1/taxonomy/import/
175
187
  {
176
188
  "taxonomy_name": "Taxonomy Name",
189
+ "taxonomy_export_id": "this_is_the_export_id",
177
190
  "taxonomy_description": "This is a description",
178
191
  "file": <file>,
179
192
  }
@@ -245,7 +258,10 @@ class TaxonomyView(ModelViewSet):
245
258
  """
246
259
  Create a new taxonomy.
247
260
  """
248
- serializer.instance = create_taxonomy(**serializer.validated_data)
261
+ try:
262
+ serializer.instance = create_taxonomy(**serializer.validated_data)
263
+ except exceptions.ValidationError as e:
264
+ raise ValidationError() from e
249
265
 
250
266
  @action(detail=True, methods=["get"])
251
267
  def export(self, request, **_kwargs) -> HttpResponse:
@@ -286,11 +302,12 @@ class TaxonomyView(ModelViewSet):
286
302
  body.is_valid(raise_exception=True)
287
303
 
288
304
  taxonomy_name = body.validated_data["taxonomy_name"]
305
+ taxonomy_export_id = body.validated_data["taxonomy_export_id"]
289
306
  taxonomy_description = body.validated_data["taxonomy_description"]
290
307
  file = body.validated_data["file"].file
291
308
  parser_format = body.validated_data["parser_format"]
292
309
 
293
- taxonomy = create_taxonomy(taxonomy_name, taxonomy_description)
310
+ taxonomy = create_taxonomy(taxonomy_name, taxonomy_description, export_id=taxonomy_export_id)
294
311
  try:
295
312
  import_success, task, _plan = import_tags(taxonomy, file, parser_format)
296
313
 
@@ -1,6 +1,6 @@
1
+ djangorestframework<4.0
1
2
  rules<4.0
2
- attrs
3
3
  celery
4
- edx-drf-extensions
5
4
  Django<5.0
6
- djangorestframework<4.0
5
+ attrs
6
+ edx-drf-extensions