openedx-learning 0.5.1__py2.py3-none-any.whl → 0.6.1__py2.py3-none-any.whl
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.
- openedx_learning/__init__.py +1 -1
- openedx_learning/contrib/media_server/views.py +2 -2
- openedx_learning/core/components/admin.py +22 -31
- openedx_learning/core/components/api.py +51 -47
- openedx_learning/core/components/migrations/0001_initial.py +12 -12
- openedx_learning/core/components/migrations/0002_alter_componentversioncontent_key.py +20 -0
- openedx_learning/core/components/models.py +37 -30
- openedx_learning/core/contents/admin.py +13 -20
- openedx_learning/core/contents/api.py +104 -94
- openedx_learning/core/contents/migrations/0001_initial.py +23 -30
- openedx_learning/core/contents/models.py +230 -149
- openedx_learning/core/publishing/migrations/0001_initial.py +2 -2
- openedx_learning/core/publishing/migrations/0002_alter_learningpackage_key_and_more.py +25 -0
- openedx_learning/core/publishing/models.py +41 -2
- openedx_learning/lib/fields.py +14 -2
- openedx_learning/lib/managers.py +6 -2
- {openedx_learning-0.5.1.dist-info → openedx_learning-0.6.1.dist-info}/METADATA +4 -4
- {openedx_learning-0.5.1.dist-info → openedx_learning-0.6.1.dist-info}/RECORD +24 -22
- openedx_tagging/core/tagging/data.py +1 -0
- openedx_tagging/core/tagging/models/base.py +36 -5
- openedx_tagging/core/tagging/rest_api/v1/serializers.py +1 -0
- {openedx_learning-0.5.1.dist-info → openedx_learning-0.6.1.dist-info}/LICENSE.txt +0 -0
- {openedx_learning-0.5.1.dist-info → openedx_learning-0.6.1.dist-info}/WHEEL +0 -0
- {openedx_learning-0.5.1.dist-info → openedx_learning-0.6.1.dist-info}/top_level.txt +0 -0
openedx_learning/__init__.py
CHANGED
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
|
9
9
|
from django.http import FileResponse, Http404
|
|
10
10
|
|
|
11
|
-
from openedx_learning.core.components.api import
|
|
11
|
+
from openedx_learning.core.components.api import look_up_component_version_content
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def component_asset(
|
|
@@ -28,7 +28,7 @@ def component_asset(
|
|
|
28
28
|
* Serving from a different domain than the rest of the service
|
|
29
29
|
"""
|
|
30
30
|
try:
|
|
31
|
-
cvc =
|
|
31
|
+
cvc = look_up_component_version_content(
|
|
32
32
|
learning_package_key, component_key, version_num, asset_path
|
|
33
33
|
)
|
|
34
34
|
except ObjectDoesNotExist:
|
|
@@ -9,7 +9,7 @@ from django.utils.safestring import SafeText
|
|
|
9
9
|
|
|
10
10
|
from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin
|
|
11
11
|
|
|
12
|
-
from .models import Component, ComponentVersion,
|
|
12
|
+
from .models import Component, ComponentVersion, ComponentVersionContent
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class ComponentVersionInline(admin.TabularInline):
|
|
@@ -48,18 +48,18 @@ class ComponentAdmin(ReadOnlyModelAdmin):
|
|
|
48
48
|
inlines = [ComponentVersionInline]
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
class
|
|
51
|
+
class ContentInline(admin.TabularInline):
|
|
52
52
|
"""
|
|
53
|
-
Django admin configuration for
|
|
53
|
+
Django admin configuration for Content
|
|
54
54
|
"""
|
|
55
|
-
model = ComponentVersion.
|
|
55
|
+
model = ComponentVersion.contents.through
|
|
56
56
|
|
|
57
57
|
def get_queryset(self, request):
|
|
58
58
|
queryset = super().get_queryset(request)
|
|
59
59
|
return queryset.select_related(
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
60
|
+
"content",
|
|
61
|
+
"content__learning_package",
|
|
62
|
+
"content__media_type",
|
|
63
63
|
"component_version",
|
|
64
64
|
"component_version__publishable_entity_version",
|
|
65
65
|
"component_version__component",
|
|
@@ -73,7 +73,7 @@ class RawContentInline(admin.TabularInline):
|
|
|
73
73
|
"rendered_data",
|
|
74
74
|
]
|
|
75
75
|
readonly_fields = [
|
|
76
|
-
"
|
|
76
|
+
"content",
|
|
77
77
|
"format_key",
|
|
78
78
|
"format_size",
|
|
79
79
|
"rendered_data",
|
|
@@ -85,7 +85,7 @@ class RawContentInline(admin.TabularInline):
|
|
|
85
85
|
|
|
86
86
|
@admin.display(description="Size")
|
|
87
87
|
def format_size(self, cvc_obj):
|
|
88
|
-
return filesizeformat(cvc_obj.
|
|
88
|
+
return filesizeformat(cvc_obj.content.size)
|
|
89
89
|
|
|
90
90
|
@admin.display(description="Key")
|
|
91
91
|
def format_key(self, cvc_obj):
|
|
@@ -108,7 +108,7 @@ class ComponentVersionAdmin(ReadOnlyModelAdmin):
|
|
|
108
108
|
"title",
|
|
109
109
|
"version_num",
|
|
110
110
|
"created",
|
|
111
|
-
"
|
|
111
|
+
"contents",
|
|
112
112
|
]
|
|
113
113
|
fields = [
|
|
114
114
|
"component",
|
|
@@ -118,7 +118,7 @@ class ComponentVersionAdmin(ReadOnlyModelAdmin):
|
|
|
118
118
|
"created",
|
|
119
119
|
]
|
|
120
120
|
list_display = ["component", "version_num", "uuid", "created"]
|
|
121
|
-
inlines = [
|
|
121
|
+
inlines = [ContentInline]
|
|
122
122
|
|
|
123
123
|
def get_queryset(self, request):
|
|
124
124
|
queryset = super().get_queryset(request)
|
|
@@ -129,12 +129,12 @@ class ComponentVersionAdmin(ReadOnlyModelAdmin):
|
|
|
129
129
|
)
|
|
130
130
|
|
|
131
131
|
|
|
132
|
-
def link_for_cvc(cvc_obj:
|
|
132
|
+
def link_for_cvc(cvc_obj: ComponentVersionContent) -> str:
|
|
133
133
|
"""
|
|
134
|
-
Get the download URL for the given
|
|
134
|
+
Get the download URL for the given ComponentVersionContent instance
|
|
135
135
|
"""
|
|
136
136
|
return "/media_server/component_asset/{}/{}/{}/{}".format(
|
|
137
|
-
cvc_obj.
|
|
137
|
+
cvc_obj.content.learning_package.key,
|
|
138
138
|
cvc_obj.component_version.component.key,
|
|
139
139
|
cvc_obj.component_version.version_num,
|
|
140
140
|
cvc_obj.key,
|
|
@@ -151,27 +151,18 @@ def format_text_for_admin_display(text: str) -> SafeText:
|
|
|
151
151
|
)
|
|
152
152
|
|
|
153
153
|
|
|
154
|
-
def content_preview(cvc_obj:
|
|
154
|
+
def content_preview(cvc_obj: ComponentVersionContent) -> SafeText:
|
|
155
155
|
"""
|
|
156
|
-
Get the HTML to display a preview of the given
|
|
156
|
+
Get the HTML to display a preview of the given ComponentVersionContent
|
|
157
157
|
"""
|
|
158
|
-
|
|
158
|
+
content_obj = cvc_obj.content
|
|
159
159
|
|
|
160
|
-
if
|
|
160
|
+
if content_obj.media_type.type == "image":
|
|
161
161
|
return format_html(
|
|
162
162
|
'<img src="{}" style="max-width: 100%;" />',
|
|
163
|
-
|
|
164
|
-
"/media_server/component_asset/{}/{}/{}/{}".format(
|
|
165
|
-
cvc_obj.raw_content.learning_package.key,
|
|
166
|
-
cvc_obj.component_version.component.key,
|
|
167
|
-
cvc_obj.component_version.version_num,
|
|
168
|
-
cvc_obj.key,
|
|
169
|
-
),
|
|
163
|
+
content_obj.file_url(),
|
|
170
164
|
)
|
|
171
165
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
return format_html("This content type cannot be displayed.")
|
|
166
|
+
return format_text_for_admin_display(
|
|
167
|
+
content_obj.text or ""
|
|
168
|
+
)
|
|
@@ -18,28 +18,32 @@ 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
|
|
22
21
|
from ..publishing import api as publishing_api
|
|
23
|
-
from .models import Component, ComponentType, ComponentVersion,
|
|
22
|
+
from .models import Component, ComponentType, ComponentVersion, ComponentVersionContent
|
|
24
23
|
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
def get_or_create_component_type_id(namespace: str, name: str) -> int:
|
|
25
|
+
def get_or_create_component_type(namespace: str, name: str) -> ComponentType:
|
|
28
26
|
"""
|
|
29
27
|
Get the ID of a ComponentType, and create if missing.
|
|
28
|
+
|
|
29
|
+
Caching Warning: Be careful about putting any caching decorator around this
|
|
30
|
+
function (e.g. ``lru_cache``). It's possible that incorrect cache values
|
|
31
|
+
could leak out in the event of a rollback–e.g. new types are introduced in
|
|
32
|
+
a large import transaction which later fails. You can safely cache the
|
|
33
|
+
results that come back from this function with a local dict in your import
|
|
34
|
+
process instead.#
|
|
30
35
|
"""
|
|
31
36
|
component_type, _created = ComponentType.objects.get_or_create(
|
|
32
37
|
namespace=namespace,
|
|
33
38
|
name=name,
|
|
34
39
|
)
|
|
35
|
-
return component_type
|
|
40
|
+
return component_type
|
|
36
41
|
|
|
37
42
|
|
|
38
43
|
def create_component(
|
|
39
44
|
learning_package_id: int,
|
|
40
45
|
/,
|
|
41
|
-
|
|
42
|
-
type_name: str,
|
|
46
|
+
component_type: ComponentType,
|
|
43
47
|
local_key: str,
|
|
44
48
|
created: datetime,
|
|
45
49
|
created_by: int | None,
|
|
@@ -47,7 +51,7 @@ def create_component(
|
|
|
47
51
|
"""
|
|
48
52
|
Create a new Component (an entity like a Problem or Video)
|
|
49
53
|
"""
|
|
50
|
-
key = f"{namespace}:{
|
|
54
|
+
key = f"{component_type.namespace}:{component_type.name}:{local_key}"
|
|
51
55
|
with atomic():
|
|
52
56
|
publishable_entity = publishing_api.create_publishable_entity(
|
|
53
57
|
learning_package_id, key, created, created_by
|
|
@@ -55,7 +59,7 @@ def create_component(
|
|
|
55
59
|
component = Component.objects.create(
|
|
56
60
|
publishable_entity=publishable_entity,
|
|
57
61
|
learning_package_id=learning_package_id,
|
|
58
|
-
|
|
62
|
+
component_type=component_type,
|
|
59
63
|
local_key=local_key,
|
|
60
64
|
)
|
|
61
65
|
return component
|
|
@@ -101,10 +105,10 @@ def create_next_version(
|
|
|
101
105
|
A very common pattern for making a new ComponentVersion is going to be "make
|
|
102
106
|
it just like the last version, except changing these one or two things".
|
|
103
107
|
Before calling this, you should create any new contents via the contents
|
|
104
|
-
API, since ``content_to_replace`` needs
|
|
108
|
+
API, since ``content_to_replace`` needs Content IDs for the values.
|
|
105
109
|
|
|
106
110
|
The ``content_to_replace`` dict is a mapping of strings representing the
|
|
107
|
-
local path/key for a file, to ``
|
|
111
|
+
local path/key for a file, to ``Content.id`` values. Using a `None` for
|
|
108
112
|
a value in this dict means to delete that key in the next version.
|
|
109
113
|
|
|
110
114
|
It is okay to mark entries for deletion that don't exist. For instance, if a
|
|
@@ -144,25 +148,25 @@ def create_next_version(
|
|
|
144
148
|
component_id=component_pk,
|
|
145
149
|
)
|
|
146
150
|
# First copy the new stuff over...
|
|
147
|
-
for key,
|
|
148
|
-
# If the
|
|
151
|
+
for key, content_pk in content_to_replace.items():
|
|
152
|
+
# If the content_pk is None, it means we want to remove the
|
|
149
153
|
# content represented by our key from the next version. Otherwise,
|
|
150
|
-
# we add our key->
|
|
151
|
-
if
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
# we add our key->content_pk mapping to the next version.
|
|
155
|
+
if content_pk is not None:
|
|
156
|
+
ComponentVersionContent.objects.create(
|
|
157
|
+
content_id=content_pk,
|
|
154
158
|
component_version=component_version,
|
|
155
159
|
key=key,
|
|
156
160
|
learner_downloadable=False,
|
|
157
161
|
)
|
|
158
162
|
# Now copy any old associations that existed, as long as they aren't
|
|
159
163
|
# in conflict with the new stuff or marked for deletion.
|
|
160
|
-
last_version_content_mapping =
|
|
161
|
-
|
|
164
|
+
last_version_content_mapping = ComponentVersionContent.objects \
|
|
165
|
+
.filter(component_version=last_version)
|
|
162
166
|
for cvrc in last_version_content_mapping:
|
|
163
167
|
if cvrc.key not in content_to_replace:
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
ComponentVersionContent.objects.create(
|
|
169
|
+
content_id=cvrc.content_id,
|
|
166
170
|
component_version=component_version,
|
|
167
171
|
key=cvrc.key,
|
|
168
172
|
learner_downloadable=cvrc.learner_downloadable,
|
|
@@ -174,8 +178,7 @@ def create_next_version(
|
|
|
174
178
|
def create_component_and_version(
|
|
175
179
|
learning_package_id: int,
|
|
176
180
|
/,
|
|
177
|
-
|
|
178
|
-
type_name: str,
|
|
181
|
+
component_type: ComponentType,
|
|
179
182
|
local_key: str,
|
|
180
183
|
title: str,
|
|
181
184
|
created: datetime,
|
|
@@ -186,7 +189,7 @@ def create_component_and_version(
|
|
|
186
189
|
"""
|
|
187
190
|
with atomic():
|
|
188
191
|
component = create_component(
|
|
189
|
-
learning_package_id,
|
|
192
|
+
learning_package_id, component_type, local_key, created, created_by
|
|
190
193
|
)
|
|
191
194
|
component_version = create_component_version(
|
|
192
195
|
component.pk,
|
|
@@ -282,27 +285,29 @@ def get_components(
|
|
|
282
285
|
qset = qset.filter(component_type__name__in=type_names)
|
|
283
286
|
if draft_title is not None:
|
|
284
287
|
qset = qset.filter(
|
|
285
|
-
publishable_entity__draft__version__title__icontains=draft_title
|
|
288
|
+
Q(publishable_entity__draft__version__title__icontains=draft_title) |
|
|
289
|
+
Q(local_key__icontains=draft_title)
|
|
286
290
|
)
|
|
287
291
|
if published_title is not None:
|
|
288
292
|
qset = qset.filter(
|
|
289
|
-
publishable_entity__published__version__title__icontains=published_title
|
|
293
|
+
Q(publishable_entity__published__version__title__icontains=published_title) |
|
|
294
|
+
Q(local_key__icontains=published_title)
|
|
290
295
|
)
|
|
291
296
|
|
|
292
297
|
return qset
|
|
293
298
|
|
|
294
299
|
|
|
295
|
-
def
|
|
300
|
+
def look_up_component_version_content(
|
|
296
301
|
learning_package_key: str,
|
|
297
302
|
component_key: str,
|
|
298
303
|
version_num: int,
|
|
299
304
|
key: Path,
|
|
300
|
-
) ->
|
|
305
|
+
) -> ComponentVersionContent:
|
|
301
306
|
"""
|
|
302
|
-
Look up
|
|
307
|
+
Look up ComponentVersionContent by human readable keys.
|
|
303
308
|
|
|
304
309
|
Can raise a django.core.exceptions.ObjectDoesNotExist error if there is no
|
|
305
|
-
matching
|
|
310
|
+
matching ComponentVersionContent.
|
|
306
311
|
"""
|
|
307
312
|
queries = (
|
|
308
313
|
Q(component_version__component__learning_package__key=learning_package_key)
|
|
@@ -310,30 +315,29 @@ def get_component_version_content(
|
|
|
310
315
|
& Q(component_version__publishable_entity_version__version_num=version_num)
|
|
311
316
|
& Q(key=key)
|
|
312
317
|
)
|
|
313
|
-
return
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def add_content_to_component_version(
|
|
318
|
+
return ComponentVersionContent.objects \
|
|
319
|
+
.select_related(
|
|
320
|
+
"content",
|
|
321
|
+
"content__media_type",
|
|
322
|
+
"component_version",
|
|
323
|
+
"component_version__component",
|
|
324
|
+
"component_version__component__learning_package",
|
|
325
|
+
).get(queries)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def create_component_version_content(
|
|
325
329
|
component_version_id: int,
|
|
330
|
+
content_id: int,
|
|
326
331
|
/,
|
|
327
|
-
raw_content_id: int,
|
|
328
332
|
key: str,
|
|
329
333
|
learner_downloadable=False,
|
|
330
|
-
) ->
|
|
334
|
+
) -> ComponentVersionContent:
|
|
331
335
|
"""
|
|
332
|
-
Add a
|
|
336
|
+
Add a Content to the given ComponentVersion
|
|
333
337
|
"""
|
|
334
|
-
cvrc, _created =
|
|
338
|
+
cvrc, _created = ComponentVersionContent.objects.get_or_create(
|
|
335
339
|
component_version_id=component_version_id,
|
|
336
|
-
|
|
340
|
+
content_id=content_id,
|
|
337
341
|
key=key,
|
|
338
342
|
learner_downloadable=learner_downloadable,
|
|
339
343
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Generated by Django 3.2.23 on 2024-
|
|
1
|
+
# Generated by Django 3.2.23 on 2024-02-06 03:23
|
|
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_publishing', '0001_initial'),
|
|
17
16
|
('oel_contents', '0001_initial'),
|
|
17
|
+
('oel_publishing', '0001_initial'),
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
operations = [
|
|
@@ -49,20 +49,20 @@ class Migration(migrations.Migration):
|
|
|
49
49
|
},
|
|
50
50
|
),
|
|
51
51
|
migrations.CreateModel(
|
|
52
|
-
name='
|
|
52
|
+
name='ComponentVersionContent',
|
|
53
53
|
fields=[
|
|
54
54
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
55
55
|
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')),
|
|
56
56
|
('key', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=500)),
|
|
57
57
|
('learner_downloadable', models.BooleanField(default=False)),
|
|
58
58
|
('component_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_components.componentversion')),
|
|
59
|
-
('
|
|
59
|
+
('content', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='oel_contents.content')),
|
|
60
60
|
],
|
|
61
61
|
),
|
|
62
62
|
migrations.AddField(
|
|
63
63
|
model_name='componentversion',
|
|
64
|
-
name='
|
|
65
|
-
field=models.ManyToManyField(related_name='component_versions', through='oel_components.
|
|
64
|
+
name='contents',
|
|
65
|
+
field=models.ManyToManyField(related_name='component_versions', through='oel_components.ComponentVersionContent', to='oel_contents.Content'),
|
|
66
66
|
),
|
|
67
67
|
migrations.AddField(
|
|
68
68
|
model_name='component',
|
|
@@ -75,16 +75,16 @@ class Migration(migrations.Migration):
|
|
|
75
75
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.learningpackage'),
|
|
76
76
|
),
|
|
77
77
|
migrations.AddIndex(
|
|
78
|
-
model_name='
|
|
79
|
-
index=models.Index(fields=['
|
|
78
|
+
model_name='componentversioncontent',
|
|
79
|
+
index=models.Index(fields=['content', 'component_version'], name='oel_cvcontent_c_cv'),
|
|
80
80
|
),
|
|
81
81
|
migrations.AddIndex(
|
|
82
|
-
model_name='
|
|
83
|
-
index=models.Index(fields=['component_version', '
|
|
82
|
+
model_name='componentversioncontent',
|
|
83
|
+
index=models.Index(fields=['component_version', 'content'], name='oel_cvcontent_cv_d'),
|
|
84
84
|
),
|
|
85
85
|
migrations.AddConstraint(
|
|
86
|
-
model_name='
|
|
87
|
-
constraint=models.UniqueConstraint(fields=('component_version', 'key'), name='
|
|
86
|
+
model_name='componentversioncontent',
|
|
87
|
+
constraint=models.UniqueConstraint(fields=('component_version', 'key'), name='oel_cvcontent_uniq_cv_key'),
|
|
88
88
|
),
|
|
89
89
|
migrations.AddIndex(
|
|
90
90
|
model_name='component',
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Generated by Django 4.2.10 on 2024-02-14 22:02
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
import openedx_learning.lib.fields
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
('oel_components', '0001_initial'),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.AlterField(
|
|
16
|
+
model_name='componentversioncontent',
|
|
17
|
+
name='key',
|
|
18
|
+
field=openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, db_column='_key', max_length=500),
|
|
19
|
+
),
|
|
20
|
+
]
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
The model hierarchy is Component -> ComponentVersion ->
|
|
2
|
+
The model hierarchy is Component -> ComponentVersion -> Content.
|
|
3
3
|
|
|
4
4
|
A Component is an entity like a Problem or Video. It has enough information to
|
|
5
5
|
identify the Component and determine what the handler should be (e.g. XBlock
|
|
@@ -10,20 +10,18 @@ that Component. Managing the publishing of these versions is handled through the
|
|
|
10
10
|
publishing app. Component maps 1:1 to PublishableEntity and ComponentVersion
|
|
11
11
|
maps 1:1 to PublishableEntityVersion.
|
|
12
12
|
|
|
13
|
-
Multiple pieces of
|
|
14
|
-
the
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
later.
|
|
13
|
+
Multiple pieces of Content may be associated with a ComponentVersion, through
|
|
14
|
+
the ComponentVersionContent model. ComponentVersionContent allows to specify a
|
|
15
|
+
ComponentVersion-local identifier. We're using this like a file path by
|
|
16
|
+
convention, but it's possible we might want to have special identifiers later.
|
|
18
17
|
"""
|
|
19
18
|
from __future__ import annotations
|
|
20
19
|
|
|
21
20
|
from django.db import models
|
|
22
21
|
|
|
23
|
-
from
|
|
24
|
-
from
|
|
25
|
-
|
|
26
|
-
from ..contents.models import RawContent
|
|
22
|
+
from ...lib.fields import case_sensitive_char_field, immutable_uuid_field, key_field
|
|
23
|
+
from ...lib.managers import WithRelationsManager
|
|
24
|
+
from ..contents.models import Content
|
|
27
25
|
from ..publishing.model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin
|
|
28
26
|
from ..publishing.models import LearningPackage
|
|
29
27
|
|
|
@@ -40,6 +38,10 @@ class ComponentType(models.Model):
|
|
|
40
38
|
type of Components–e.g. marking certain types of XBlocks as approved vs.
|
|
41
39
|
experimental for use in libraries.
|
|
42
40
|
"""
|
|
41
|
+
# We don't need the app default of 8-bytes for this primary key, but there
|
|
42
|
+
# is just a tiny chance that we'll use ComponentType in a novel, user-
|
|
43
|
+
# customizable way that will require more than 32K entries. So let's use a
|
|
44
|
+
# 4-byte primary key.
|
|
43
45
|
id = models.AutoField(primary_key=True)
|
|
44
46
|
|
|
45
47
|
# namespace and name work together to help figure out what Component needs
|
|
@@ -79,7 +81,7 @@ class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
|
|
|
79
81
|
Problem), but little beyond that.
|
|
80
82
|
|
|
81
83
|
A Component will have many ComponentVersions over time, and most metadata is
|
|
82
|
-
associated with the ComponentVersion model and the
|
|
84
|
+
associated with the ComponentVersion model and the Content that
|
|
83
85
|
ComponentVersions are associated with.
|
|
84
86
|
|
|
85
87
|
A Component belongs to exactly one LearningPackage.
|
|
@@ -187,8 +189,8 @@ class ComponentVersion(PublishableEntityVersionMixin):
|
|
|
187
189
|
"""
|
|
188
190
|
A particular version of a Component.
|
|
189
191
|
|
|
190
|
-
This holds the content using a M:M relationship with
|
|
191
|
-
|
|
192
|
+
This holds the content using a M:M relationship with Content via
|
|
193
|
+
ComponentVersionContent.
|
|
192
194
|
"""
|
|
193
195
|
# Tell mypy what type our objects manager has.
|
|
194
196
|
# It's actually PublishableEntityVersionMixinManager, but that has the exact
|
|
@@ -202,11 +204,11 @@ class ComponentVersion(PublishableEntityVersionMixin):
|
|
|
202
204
|
Component, on_delete=models.CASCADE, related_name="versions"
|
|
203
205
|
)
|
|
204
206
|
|
|
205
|
-
# The
|
|
207
|
+
# The contents hold the actual interesting data associated with this
|
|
206
208
|
# ComponentVersion.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
through="
|
|
209
|
+
contents: models.ManyToManyField[Content, ComponentVersionContent] = models.ManyToManyField(
|
|
210
|
+
Content,
|
|
211
|
+
through="ComponentVersionContent",
|
|
210
212
|
related_name="component_versions",
|
|
211
213
|
)
|
|
212
214
|
|
|
@@ -215,32 +217,37 @@ class ComponentVersion(PublishableEntityVersionMixin):
|
|
|
215
217
|
verbose_name_plural = "Component Versions"
|
|
216
218
|
|
|
217
219
|
|
|
218
|
-
class
|
|
220
|
+
class ComponentVersionContent(models.Model):
|
|
219
221
|
"""
|
|
220
|
-
Determines the
|
|
222
|
+
Determines the Content for a given ComponentVersion.
|
|
221
223
|
|
|
222
224
|
An ComponentVersion may be associated with multiple pieces of binary data.
|
|
223
225
|
For instance, a Video ComponentVersion might be associated with multiple
|
|
224
226
|
transcripts in different languages.
|
|
225
227
|
|
|
226
|
-
When
|
|
228
|
+
When Content is associated with an ComponentVersion, it has some local
|
|
227
229
|
key that is unique within the the context of that ComponentVersion. This
|
|
228
230
|
allows the ComponentVersion to do things like store an image file and
|
|
229
231
|
reference it by a "path" key.
|
|
230
232
|
|
|
231
|
-
|
|
232
|
-
even across LearningPackages.
|
|
233
|
+
Content is immutable and sharable across multiple ComponentVersions.
|
|
233
234
|
"""
|
|
234
235
|
|
|
235
|
-
raw_content = models.ForeignKey(RawContent, on_delete=models.RESTRICT)
|
|
236
236
|
component_version = models.ForeignKey(ComponentVersion, on_delete=models.CASCADE)
|
|
237
|
+
content = models.ForeignKey(Content, on_delete=models.RESTRICT)
|
|
237
238
|
|
|
238
239
|
uuid = immutable_uuid_field()
|
|
239
|
-
|
|
240
|
+
|
|
241
|
+
# "key" is a reserved word for MySQL, so we're temporarily using the column
|
|
242
|
+
# name of "_key" to avoid breaking downstream tooling. A possible
|
|
243
|
+
# alternative name for this would be "path", since it's most often used as
|
|
244
|
+
# an internal file path. However, we might also want to put special
|
|
245
|
+
# identifiers that don't map as cleanly to file paths at some point.
|
|
246
|
+
key = key_field(db_column="_key")
|
|
240
247
|
|
|
241
248
|
# Long explanation for the ``learner_downloadable`` field:
|
|
242
249
|
#
|
|
243
|
-
# Is this
|
|
250
|
+
# Is this Content downloadable during the learning experience? This is
|
|
244
251
|
# NOT about public vs. private permissions on course assets, as that will be
|
|
245
252
|
# a policy that can be changed independently of new versions of the content.
|
|
246
253
|
# For instance, a course team could decide to flip their course assets from
|
|
@@ -282,16 +289,16 @@ class ComponentVersionRawContent(models.Model):
|
|
|
282
289
|
# with two different identifiers, that is permitted.
|
|
283
290
|
models.UniqueConstraint(
|
|
284
291
|
fields=["component_version", "key"],
|
|
285
|
-
name="
|
|
292
|
+
name="oel_cvcontent_uniq_cv_key",
|
|
286
293
|
),
|
|
287
294
|
]
|
|
288
295
|
indexes = [
|
|
289
296
|
models.Index(
|
|
290
|
-
fields=["
|
|
291
|
-
name="
|
|
297
|
+
fields=["content", "component_version"],
|
|
298
|
+
name="oel_cvcontent_c_cv",
|
|
292
299
|
),
|
|
293
300
|
models.Index(
|
|
294
|
-
fields=["component_version", "
|
|
295
|
-
name="
|
|
301
|
+
fields=["component_version", "content"],
|
|
302
|
+
name="oel_cvcontent_cv_d",
|
|
296
303
|
),
|
|
297
304
|
]
|
|
@@ -6,13 +6,13 @@ from django.utils.html import format_html
|
|
|
6
6
|
|
|
7
7
|
from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin
|
|
8
8
|
|
|
9
|
-
from .models import
|
|
9
|
+
from .models import Content
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
@admin.register(
|
|
13
|
-
class
|
|
12
|
+
@admin.register(Content)
|
|
13
|
+
class ContentAdmin(ReadOnlyModelAdmin):
|
|
14
14
|
"""
|
|
15
|
-
Django admin for
|
|
15
|
+
Django admin for Content model
|
|
16
16
|
"""
|
|
17
17
|
list_display = [
|
|
18
18
|
"hash_digest",
|
|
@@ -21,6 +21,7 @@ class RawContentAdmin(ReadOnlyModelAdmin):
|
|
|
21
21
|
"media_type",
|
|
22
22
|
"size",
|
|
23
23
|
"created",
|
|
24
|
+
"has_file",
|
|
24
25
|
]
|
|
25
26
|
fields = [
|
|
26
27
|
"learning_package",
|
|
@@ -30,30 +31,22 @@ class RawContentAdmin(ReadOnlyModelAdmin):
|
|
|
30
31
|
"created",
|
|
31
32
|
"file_link",
|
|
32
33
|
"text_preview",
|
|
33
|
-
|
|
34
|
-
readonly_fields = [
|
|
35
|
-
"learning_package",
|
|
36
|
-
"hash_digest",
|
|
37
|
-
"media_type",
|
|
38
|
-
"size",
|
|
39
|
-
"created",
|
|
40
|
-
"file_link",
|
|
41
|
-
"text_preview",
|
|
34
|
+
"has_file",
|
|
42
35
|
]
|
|
43
36
|
list_filter = ("media_type", "learning_package")
|
|
44
37
|
search_fields = ("hash_digest",)
|
|
45
38
|
|
|
46
|
-
def file_link(self,
|
|
39
|
+
def file_link(self, content: Content):
|
|
40
|
+
if not content.has_file:
|
|
41
|
+
return ""
|
|
42
|
+
|
|
47
43
|
return format_html(
|
|
48
44
|
'<a href="{}">Download</a>',
|
|
49
|
-
|
|
45
|
+
content.file_url(),
|
|
50
46
|
)
|
|
51
47
|
|
|
52
|
-
def text_preview(self,
|
|
53
|
-
if not hasattr(raw_content, "text_content"):
|
|
54
|
-
return "(not available)"
|
|
55
|
-
|
|
48
|
+
def text_preview(self, content: Content):
|
|
56
49
|
return format_html(
|
|
57
50
|
'<pre style="white-space: pre-wrap;">\n{}\n</pre>',
|
|
58
|
-
|
|
51
|
+
content.text,
|
|
59
52
|
)
|