openedx-learning 0.4.4__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 (111) hide show
  1. {openedx-learning-0.4.4/openedx_learning.egg-info → openedx-learning-0.5.1}/PKG-INFO +1 -1
  2. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/__init__.py +1 -1
  3. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/components/admin.py +3 -4
  4. openedx-learning-0.5.1/openedx_learning/core/components/api.py +340 -0
  5. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/components/migrations/0001_initial.py +22 -7
  6. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/components/models.py +78 -31
  7. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/contents/api.py +7 -5
  8. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/contents/migrations/0001_initial.py +3 -3
  9. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/contents/models.py +5 -5
  10. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/publishing/admin.py +3 -1
  11. openedx-learning-0.5.1/openedx_learning/core/publishing/api.py +427 -0
  12. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/publishing/migrations/0001_initial.py +7 -6
  13. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/publishing/model_mixins.py +98 -44
  14. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/publishing/models.py +26 -3
  15. openedx-learning-0.5.1/openedx_learning/lib/managers.py +38 -0
  16. {openedx-learning-0.4.4 → openedx-learning-0.5.1/openedx_learning.egg-info}/PKG-INFO +1 -1
  17. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning.egg-info/SOURCES.txt +2 -1
  18. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/api.py +8 -0
  19. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/api.py +1 -1
  20. openedx-learning-0.5.1/openedx_tagging/core/tagging/migrations/0015_taxonomy_export_id.py +38 -0
  21. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/models/base.py +23 -0
  22. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/v1/serializers.py +3 -0
  23. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/v1/views.py +19 -2
  24. openedx-learning-0.4.4/openedx_learning/core/components/api.py +0 -147
  25. openedx-learning-0.4.4/openedx_learning/core/publishing/api.py +0 -245
  26. openedx-learning-0.4.4/openedx_learning/core/publishing/migrations/0002_alter_fk_on_delete.py +0 -30
  27. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/CHANGELOG.rst +0 -0
  28. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/LICENSE.txt +0 -0
  29. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/MANIFEST.in +0 -0
  30. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/README.rst +0 -0
  31. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/contrib/__init__.py +0 -0
  32. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/contrib/media_server/__init__.py +0 -0
  33. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/contrib/media_server/apps.py +0 -0
  34. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/contrib/media_server/urls.py +0 -0
  35. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/contrib/media_server/views.py +0 -0
  36. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/__init__.py +0 -0
  37. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/components/__init__.py +0 -0
  38. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/components/apps.py +0 -0
  39. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/components/migrations/__init__.py +0 -0
  40. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/contents/__init__.py +0 -0
  41. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/contents/admin.py +0 -0
  42. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/contents/apps.py +0 -0
  43. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/contents/migrations/__init__.py +0 -0
  44. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/publishing/__init__.py +0 -0
  45. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/publishing/apps.py +0 -0
  46. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/core/publishing/migrations/__init__.py +0 -0
  47. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/lib/__init__.py +0 -0
  48. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/lib/admin_utils.py +0 -0
  49. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/lib/cache.py +0 -0
  50. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/lib/collations.py +0 -0
  51. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/lib/fields.py +0 -0
  52. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/lib/test_utils.py +0 -0
  53. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/lib/validators.py +0 -0
  54. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/rest_api/__init__.py +0 -0
  55. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/rest_api/apps.py +0 -0
  56. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/rest_api/urls.py +0 -0
  57. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/rest_api/v1/__init__.py +0 -0
  58. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/rest_api/v1/components.py +0 -0
  59. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning/rest_api/v1/urls.py +0 -0
  60. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning.egg-info/dependency_links.txt +0 -0
  61. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning.egg-info/not-zip-safe +0 -0
  62. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning.egg-info/requires.txt +2 -2
  63. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_learning.egg-info/top_level.txt +0 -0
  64. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/__init__.py +0 -0
  65. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/__init__.py +0 -0
  66. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/__init__.py +0 -0
  67. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/admin.py +0 -0
  68. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/apps.py +0 -0
  69. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/data.py +0 -0
  70. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/__init__.py +0 -0
  71. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/actions.py +0 -0
  72. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/exceptions.py +0 -0
  73. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/import_plan.py +0 -0
  74. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/parsers.py +0 -0
  75. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/tasks.py +0 -0
  76. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/template.csv +0 -0
  77. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/import_export/template.json +0 -0
  78. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0001_initial.py +0 -0
  79. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0001_squashed.py +0 -0
  80. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0002_auto_20230718_2026.py +0 -0
  81. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0003_auto_20230721_1238.py +0 -0
  82. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0004_auto_20230723_2001.py +0 -0
  83. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0005_language_taxonomy.py +0 -0
  84. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0006_alter_objecttag_unique_together.py +0 -0
  85. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0006_auto_20230802_1631.py +0 -0
  86. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0007_tag_import_task_log_null_fix.py +0 -0
  87. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0008_taxonomy_description_not_null.py +0 -0
  88. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0009_alter_objecttag_object_id.py +0 -0
  89. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0010_cleanups.py +0 -0
  90. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0011_remove_required.py +0 -0
  91. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0012_language_taxonomy.py +0 -0
  92. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0013_tag_parent_blank.py +0 -0
  93. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/0014_minor_fixes.py +0 -0
  94. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/migrations/__init__.py +0 -0
  95. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/models/__init__.py +0 -0
  96. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/models/import_export.py +0 -0
  97. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/models/system_defined.py +0 -0
  98. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/models/utils.py +0 -0
  99. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/__init__.py +0 -0
  100. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/paginators.py +0 -0
  101. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/urls.py +0 -0
  102. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/utils.py +0 -0
  103. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/v1/__init__.py +0 -0
  104. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/v1/permissions.py +0 -0
  105. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/v1/urls.py +0 -0
  106. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rest_api/v1/views_import.py +0 -0
  107. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/rules.py +0 -0
  108. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/openedx_tagging/core/tagging/urls.py +0 -0
  109. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/requirements/base.in +0 -0
  110. {openedx-learning-0.4.4 → openedx-learning-0.5.1}/setup.cfg +0 -0
  111. {openedx-learning-0.4.4 → 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.4.4
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.4.4"
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
 
@@ -0,0 +1,340 @@
1
+ """
2
+ Components API (warning: UNSTABLE, in progress API)
3
+
4
+ These functions are often going to be simple-looking write operations, but there
5
+ is bookkeeping logic needed across multiple models to keep state consistent. You
6
+ can read from the models directly for various queries if necessary–we do this in
7
+ the Django Admin for instance. But you should NEVER mutate this app's models
8
+ directly, since there might be other related models that you may not know about.
9
+
10
+ Please look at the models.py file for more information about the kinds of data
11
+ are stored in this app.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ from django.db.models import Q, QuerySet
19
+ from django.db.transaction import atomic
20
+
21
+ from ...lib.cache import lru_cache
22
+ from ..publishing import api as publishing_api
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
36
+
37
+
38
+ def create_component(
39
+ learning_package_id: int,
40
+ /,
41
+ namespace: str,
42
+ type_name: str,
43
+ local_key: str,
44
+ created: datetime,
45
+ created_by: int | None,
46
+ ) -> Component:
47
+ """
48
+ Create a new Component (an entity like a Problem or Video)
49
+ """
50
+ key = f"{namespace}:{type_name}@{local_key}"
51
+ with atomic():
52
+ publishable_entity = publishing_api.create_publishable_entity(
53
+ learning_package_id, key, created, created_by
54
+ )
55
+ component = Component.objects.create(
56
+ publishable_entity=publishable_entity,
57
+ learning_package_id=learning_package_id,
58
+ component_type_id=get_or_create_component_type_id(namespace, type_name),
59
+ local_key=local_key,
60
+ )
61
+ return component
62
+
63
+
64
+ def create_component_version(
65
+ component_pk: int,
66
+ /,
67
+ version_num: int,
68
+ title: str,
69
+ created: datetime,
70
+ created_by: int | None,
71
+ ) -> ComponentVersion:
72
+ """
73
+ Create a new ComponentVersion
74
+ """
75
+ with atomic():
76
+ publishable_entity_version = publishing_api.create_publishable_entity_version(
77
+ component_pk,
78
+ version_num=version_num,
79
+ title=title,
80
+ created=created,
81
+ created_by=created_by,
82
+ )
83
+ component_version = ComponentVersion.objects.create(
84
+ publishable_entity_version=publishable_entity_version,
85
+ component_id=component_pk,
86
+ )
87
+ return component_version
88
+
89
+
90
+ def create_next_version(
91
+ component_pk: int,
92
+ /,
93
+ title: str,
94
+ content_to_replace: dict[str, int | None],
95
+ created: datetime,
96
+ created_by: int | None = None,
97
+ ) -> ComponentVersion:
98
+ """
99
+ Create a new ComponentVersion based on the most recent version.
100
+
101
+ A very common pattern for making a new ComponentVersion is going to be "make
102
+ it just like the last version, except changing these one or two things".
103
+ Before calling this, you should create any new contents via the contents
104
+ API, since ``content_to_replace`` needs RawContent IDs for the values.
105
+
106
+ The ``content_to_replace`` dict is a mapping of strings representing the
107
+ local path/key for a file, to ``RawContent.id`` values. Using a `None` for
108
+ a value in this dict means to delete that key in the next version.
109
+
110
+ It is okay to mark entries for deletion that don't exist. For instance, if a
111
+ version has ``a.txt`` and ``b.txt``, sending a ``content_to_replace`` value
112
+ of ``{"a.txt": None, "c.txt": None}`` will remove ``a.txt`` from the next
113
+ version, leave ``b.txt`` alone, and will not error–even though there is no
114
+ ``c.txt`` in the previous version. This is to make it a little more
115
+ convenient to remove paths (e.g. due to deprecation) without having to
116
+ always check for its existence first.
117
+
118
+ TODO: Have to add learning_downloadable info to this when it comes time to
119
+ support static asset download.
120
+ """
121
+ # This needs to grab the highest version_num for this Publishable Entity.
122
+ # This will often be the Draft version, but not always. For instance, if
123
+ # an entity was soft-deleted, the draft would be None, but the version_num
124
+ # should pick up from the last edited version. Likewise, a Draft might get
125
+ # reverted to an earlier version, but we want the latest version_num when
126
+ # creating the next version.
127
+ component = Component.objects.get(pk=component_pk)
128
+ last_version = component.versioning.latest
129
+ if last_version is None:
130
+ next_version_num = 1
131
+ else:
132
+ next_version_num = last_version.version_num + 1
133
+
134
+ with atomic():
135
+ publishable_entity_version = publishing_api.create_publishable_entity_version(
136
+ component_pk,
137
+ version_num=next_version_num,
138
+ title=title,
139
+ created=created,
140
+ created_by=created_by,
141
+ )
142
+ component_version = ComponentVersion.objects.create(
143
+ publishable_entity_version=publishable_entity_version,
144
+ component_id=component_pk,
145
+ )
146
+ # First copy the new stuff over...
147
+ for key, raw_content_pk in content_to_replace.items():
148
+ # If the raw_content_pk is None, it means we want to remove the
149
+ # content represented by our key from the next version. Otherwise,
150
+ # we add our key->raw_content_pk mapping to the next version.
151
+ if raw_content_pk is not None:
152
+ ComponentVersionRawContent.objects.create(
153
+ raw_content_id=raw_content_pk,
154
+ component_version=component_version,
155
+ key=key,
156
+ learner_downloadable=False,
157
+ )
158
+ # Now copy any old associations that existed, as long as they aren't
159
+ # in conflict with the new stuff or marked for deletion.
160
+ last_version_content_mapping = ComponentVersionRawContent.objects \
161
+ .filter(component_version=last_version)
162
+ for cvrc in last_version_content_mapping:
163
+ if cvrc.key not in content_to_replace:
164
+ ComponentVersionRawContent.objects.create(
165
+ raw_content_id=cvrc.raw_content_id,
166
+ component_version=component_version,
167
+ key=cvrc.key,
168
+ learner_downloadable=cvrc.learner_downloadable,
169
+ )
170
+
171
+ return component_version
172
+
173
+
174
+ def create_component_and_version(
175
+ learning_package_id: int,
176
+ /,
177
+ namespace: str,
178
+ type_name: str,
179
+ local_key: str,
180
+ title: str,
181
+ created: datetime,
182
+ created_by: int | None = None,
183
+ ) -> tuple[Component, ComponentVersion]:
184
+ """
185
+ Create a Component and associated ComponentVersion atomically
186
+ """
187
+ with atomic():
188
+ component = create_component(
189
+ learning_package_id, namespace, type_name, local_key, created, created_by
190
+ )
191
+ component_version = create_component_version(
192
+ component.pk,
193
+ version_num=1,
194
+ title=title,
195
+ created=created,
196
+ created_by=created_by,
197
+ )
198
+ return (component, component_version)
199
+
200
+
201
+ def get_component(component_pk: int, /) -> Component:
202
+ """
203
+ Get Component by its primary key.
204
+
205
+ This is the same as the PublishableEntity's ID primary key.
206
+ """
207
+ return Component.with_publishing_relations.get(pk=component_pk)
208
+
209
+
210
+ def get_component_by_key(
211
+ learning_package_id: int,
212
+ /,
213
+ namespace: str,
214
+ type_name: str,
215
+ local_key: str,
216
+ ) -> Component:
217
+ """
218
+ Get a Component by its unique (namespace, type, local_key) tuple.
219
+ """
220
+ return Component.with_publishing_relations \
221
+ .get(
222
+ learning_package_id=learning_package_id,
223
+ component_type__namespace=namespace,
224
+ component_type__name=type_name,
225
+ local_key=local_key,
226
+ )
227
+
228
+
229
+ def component_exists_by_key(
230
+ learning_package_id: int,
231
+ /,
232
+ namespace: str,
233
+ type_name: str,
234
+ local_key: str
235
+ ) -> bool:
236
+ """
237
+ Return True/False for whether a Component exists.
238
+
239
+ Note that a Component still exists even if it's been soft-deleted (there's
240
+ no current Draft version for it), or if it's been unpublished.
241
+ """
242
+ try:
243
+ _component = Component.objects.only('pk', 'component_type').get(
244
+ learning_package_id=learning_package_id,
245
+ component_type__namespace=namespace,
246
+ component_type__name=type_name,
247
+ local_key=local_key,
248
+ )
249
+ return True
250
+ except Component.DoesNotExist:
251
+ return False
252
+
253
+
254
+ def get_components(
255
+ learning_package_id: int,
256
+ /,
257
+ draft: bool | None = None,
258
+ published: bool | None = None,
259
+ namespace: str | None = None,
260
+ type_names: list[str] | None = None,
261
+ draft_title: str | None = None,
262
+ published_title: str | None = None,
263
+ ) -> QuerySet[Component]:
264
+ """
265
+ Fetch a QuerySet of Components for a LearningPackage using various filters.
266
+
267
+ This method will pre-load all the relations that we need in order to get
268
+ info from the Component's draft and published versions, since we'll be
269
+ referencing these a lot.
270
+ """
271
+ qset = Component.with_publishing_relations \
272
+ .filter(learning_package_id=learning_package_id) \
273
+ .order_by('pk')
274
+
275
+ if draft is not None:
276
+ qset = qset.filter(publishable_entity__draft__version__isnull=not draft)
277
+ if published is not None:
278
+ qset = qset.filter(publishable_entity__published__version__isnull=not published)
279
+ if namespace is not None:
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)
283
+ if draft_title is not None:
284
+ qset = qset.filter(
285
+ publishable_entity__draft__version__title__icontains=draft_title
286
+ )
287
+ if published_title is not None:
288
+ qset = qset.filter(
289
+ publishable_entity__published__version__title__icontains=published_title
290
+ )
291
+
292
+ return qset
293
+
294
+
295
+ def get_component_version_content(
296
+ learning_package_key: str,
297
+ component_key: str,
298
+ version_num: int,
299
+ key: Path,
300
+ ) -> ComponentVersionRawContent:
301
+ """
302
+ Look up ComponentVersionRawContent by human readable keys.
303
+
304
+ Can raise a django.core.exceptions.ObjectDoesNotExist error if there is no
305
+ matching ComponentVersionRawContent.
306
+ """
307
+ queries = (
308
+ Q(component_version__component__learning_package__key=learning_package_key)
309
+ & Q(component_version__component__publishable_entity__key=component_key)
310
+ & Q(component_version__publishable_entity_version__version_num=version_num)
311
+ & Q(key=key)
312
+ )
313
+ return ComponentVersionRawContent.objects \
314
+ .select_related(
315
+ "raw_content",
316
+ "raw_content__media_type",
317
+ "raw_content__textcontent",
318
+ "component_version",
319
+ "component_version__component",
320
+ "component_version__component__learning_package",
321
+ ).get(queries)
322
+
323
+
324
+ def add_content_to_component_version(
325
+ component_version_id: int,
326
+ /,
327
+ raw_content_id: int,
328
+ key: str,
329
+ learner_downloadable=False,
330
+ ) -> ComponentVersionRawContent:
331
+ """
332
+ Add a RawContent to the given ComponentVersion
333
+ """
334
+ cvrc, _created = ComponentVersionRawContent.objects.get_or_create(
335
+ component_version_id=component_version_id,
336
+ raw_content_id=raw_content_id,
337
+ key=key,
338
+ learner_downloadable=learner_downloadable,
339
+ )
340
+ return cvrc
@@ -1,4 +1,4 @@
1
- # Generated by Django 3.2.23 on 2023-12-04 00:41
1
+ # Generated by Django 3.2.23 on 2024-01-31 05:34
2
2
 
3
3
  import uuid
4
4
 
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
13
13
  initial = True
14
14
 
15
15
  dependencies = [
16
- ('oel_publishing', '0002_alter_fk_on_delete'),
16
+ ('oel_publishing', '0001_initial'),
17
17
  ('oel_contents', '0001_initial'),
18
18
  ]
19
19
 
@@ -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
  ]
@@ -21,12 +21,52 @@ from __future__ import annotations
21
21
  from django.db import models
22
22
 
23
23
  from openedx_learning.lib.fields import case_sensitive_char_field, immutable_uuid_field, key_field
24
+ from openedx_learning.lib.managers import WithRelationsManager
24
25
 
25
26
  from ..contents.models import RawContent
26
27
  from ..publishing.model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin
27
28
  from ..publishing.models import LearningPackage
28
29
 
29
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
+
30
70
  class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
31
71
  """
32
72
  This represents any Component that has ever existed in a LearningPackage.
@@ -44,6 +84,12 @@ class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
44
84
 
45
85
  A Component belongs to exactly one LearningPackage.
46
86
 
87
+ A Component is 1:1 with PublishableEntity and has matching primary key
88
+ values. More specifically, ``Component.pk`` maps to
89
+ ``Component.publishable_entity_id``, and any place where the Publishing API
90
+ module expects to get a ``PublishableEntity.id``, you can use a
91
+ ``Component.pk`` instead.
92
+
47
93
  Identifiers
48
94
  -----------
49
95
 
@@ -56,7 +102,7 @@ class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
56
102
  -----------------
57
103
 
58
104
  The ``key`` field on Component's ``publishable_entity`` is dervied from the
59
- ``(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
60
106
  changing the keys yet, but if we do, those values need to be kept in sync.
61
107
 
62
108
  How build on this model
@@ -68,63 +114,64 @@ class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
68
114
  # Tell mypy what type our objects manager has.
69
115
  # It's actually PublishableEntityMixinManager, but that has the exact same
70
116
  # interface as the base manager class.
71
- objects: models.Manager[Component]
117
+ objects: models.Manager[Component] = WithRelationsManager(
118
+ 'component_type'
119
+ )
120
+
121
+ with_publishing_relations: models.Manager[Component] = WithRelationsManager(
122
+ 'component_type',
123
+ 'publishable_entity',
124
+ 'publishable_entity__draft__version',
125
+ 'publishable_entity__draft__version__componentversion',
126
+ 'publishable_entity__published__version',
127
+ 'publishable_entity__published__version__componentversion',
128
+ )
72
129
 
73
130
  # This foreign key is technically redundant because we're already locked to
74
- # a single LearningPackage through our publishable_entity relation. However, having
75
- # this foreign key directly allows us to make indexes that efficiently
131
+ # a single LearningPackage through our publishable_entity relation. However,
132
+ # having this foreign key directly allows us to make indexes that efficiently
76
133
  # query by other Component fields within a given LearningPackage, which is
77
134
  # going to be a common use case (and we can't make a compound index using
78
135
  # columns from different tables).
79
136
  learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE)
80
137
 
81
- # namespace and type work together to help figure out what Component needs
82
- # to handle this data. A namespace is *required*. The namespace for XBlocks
83
- # is "xblock.v1" (to match the setup.py entrypoint naming scheme).
84
- namespace = case_sensitive_char_field(max_length=100, blank=False)
85
-
86
- # type is a way to help sub-divide namespace if that's convenient. This
87
- # field cannot be null, but it can be blank if it's not necessary. For an
88
- # XBlock, type corresponds to tag, e.g. "video". It's also the block_type in
89
- # the UsageKey.
90
- type = case_sensitive_char_field(max_length=100, blank=True)
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)
91
141
 
92
- # local_key is an identifier that is local to the (namespace, type). The
93
- # publishable.key should be calculated as a combination of (namespace, type,
94
- # 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.
95
145
  local_key = key_field()
96
146
 
97
147
  class Meta:
98
148
  constraints = [
99
- # The combination of (namespace, type, local_key) is unique within
149
+ # The combination of (component_type, local_key) is unique within
100
150
  # a given LearningPackage. Note that this means it is possible to
101
- # have two Components that have the exact same local_key. An XBlock
102
- # would be modeled as namespace="xblock.v1" with the type as the
103
- # block_type, so the local_key would only be the block_id (the
104
- # 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".
105
155
  models.UniqueConstraint(
106
156
  fields=[
107
157
  "learning_package",
108
- "namespace",
109
- "type",
158
+ "component_type",
110
159
  "local_key",
111
160
  ],
112
- name="oel_component_uniq_lc_ns_t_lk",
161
+ name="oel_component_uniq_lc_ct_lk",
113
162
  ),
114
163
  ]
115
164
  indexes = [
116
- # Global Namespace/Type/Local-Key Index:
165
+ # Global Component-Type/Local-Key Index:
117
166
  # * Search by the different Components fields across all Learning
118
167
  # Packages on the site. This would be a support-oriented tool
119
168
  # from Django Admin.
120
169
  models.Index(
121
170
  fields=[
122
- "learning_package",
123
- "namespace",
124
- "type",
171
+ "component_type",
125
172
  "local_key",
126
173
  ],
127
- name="oel_component_idx_lc_ns_t_lk",
174
+ name="oel_component_idx_ct_lk",
128
175
  ),
129
176
  ]
130
177
 
@@ -133,7 +180,7 @@ class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
133
180
  verbose_name_plural = "Components"
134
181
 
135
182
  def __str__(self):
136
- return f"{self.namespace}:{self.type}:{self.local_key}"
183
+ return f"{self.component_type.namespace}:{self.component_type.name}:{self.local_key}"
137
184
 
138
185
 
139
186
  class ComponentVersion(PublishableEntityVersionMixin):
@@ -12,14 +12,14 @@ 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
 
21
20
  def create_raw_content(
22
21
  learning_package_id: int,
22
+ /,
23
23
  data_bytes: bytes,
24
24
  mime_type: str,
25
25
  created: datetime,
@@ -32,7 +32,7 @@ def create_raw_content(
32
32
 
33
33
  raw_content = RawContent.objects.create(
34
34
  learning_package_id=learning_package_id,
35
- media_type_id=get_media_type_id(mime_type),
35
+ media_type_id=get_or_create_media_type_id(mime_type),
36
36
  hash_digest=hash_digest,
37
37
  size=len(data_bytes),
38
38
  created=created,
@@ -57,7 +57,7 @@ def create_text_from_raw_content(raw_content: RawContent, encoding="utf-8-sig")
57
57
 
58
58
 
59
59
  @lru_cache(maxsize=128)
60
- def get_media_type_id(mime_type: str) -> int:
60
+ def get_or_create_media_type_id(mime_type: str) -> int:
61
61
  """
62
62
  Return the MediaType.id for the desired mime_type string.
63
63
 
@@ -91,6 +91,7 @@ def get_media_type_id(mime_type: str) -> int:
91
91
 
92
92
  def get_or_create_raw_content(
93
93
  learning_package_id: int,
94
+ /,
94
95
  data_bytes: bytes,
95
96
  mime_type: str,
96
97
  created: datetime,
@@ -117,6 +118,7 @@ def get_or_create_raw_content(
117
118
 
118
119
  def get_or_create_text_content_from_bytes(
119
120
  learning_package_id: int,
121
+ /,
120
122
  data_bytes: bytes,
121
123
  mime_type: str,
122
124
  created: datetime,