openedx-learning 0.13.1__py2.py3-none-any.whl → 0.15.0__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/apps/authoring/components/admin.py +17 -25
- openedx_learning/apps/authoring/components/api.py +85 -5
- openedx_learning/apps/authoring/contents/admin.py +29 -10
- openedx_learning/apps/authoring/contents/models.py +61 -20
- {openedx_learning-0.13.1.dist-info → openedx_learning-0.15.0.dist-info}/METADATA +4 -4
- {openedx_learning-0.13.1.dist-info → openedx_learning-0.15.0.dist-info}/RECORD +11 -11
- {openedx_learning-0.13.1.dist-info → openedx_learning-0.15.0.dist-info}/WHEEL +1 -1
- openedx_tagging/core/tagging/import_export/api.py +1 -1
- {openedx_learning-0.13.1.dist-info → openedx_learning-0.15.0.dist-info}/LICENSE.txt +0 -0
- {openedx_learning-0.13.1.dist-info → openedx_learning-0.15.0.dist-info}/top_level.txt +0 -0
openedx_learning/__init__.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Django admin for components models
|
|
3
3
|
"""
|
|
4
|
+
import base64
|
|
5
|
+
|
|
4
6
|
from django.contrib import admin
|
|
5
7
|
from django.template.defaultfilters import filesizeformat
|
|
6
8
|
from django.urls import reverse
|
|
@@ -67,19 +69,22 @@ class ContentInline(admin.TabularInline):
|
|
|
67
69
|
)
|
|
68
70
|
|
|
69
71
|
fields = [
|
|
70
|
-
"
|
|
72
|
+
"key",
|
|
71
73
|
"format_size",
|
|
72
74
|
"learner_downloadable",
|
|
73
75
|
"rendered_data",
|
|
74
76
|
]
|
|
75
77
|
readonly_fields = [
|
|
76
78
|
"content",
|
|
77
|
-
"
|
|
79
|
+
"key",
|
|
78
80
|
"format_size",
|
|
79
81
|
"rendered_data",
|
|
80
82
|
]
|
|
81
83
|
extra = 0
|
|
82
84
|
|
|
85
|
+
def has_file(self, cvc_obj):
|
|
86
|
+
return cvc_obj.content.has_file
|
|
87
|
+
|
|
83
88
|
def rendered_data(self, cvc_obj):
|
|
84
89
|
return content_preview(cvc_obj)
|
|
85
90
|
|
|
@@ -87,15 +92,6 @@ class ContentInline(admin.TabularInline):
|
|
|
87
92
|
def format_size(self, cvc_obj):
|
|
88
93
|
return filesizeformat(cvc_obj.content.size)
|
|
89
94
|
|
|
90
|
-
@admin.display(description="Key")
|
|
91
|
-
def format_key(self, cvc_obj):
|
|
92
|
-
return format_html(
|
|
93
|
-
'<a href="{}">{}</a>',
|
|
94
|
-
link_for_cvc(cvc_obj),
|
|
95
|
-
# reverse("admin:components_content_change", args=(cvc_obj.content_id,)),
|
|
96
|
-
cvc_obj.key,
|
|
97
|
-
)
|
|
98
|
-
|
|
99
95
|
|
|
100
96
|
@admin.register(ComponentVersion)
|
|
101
97
|
class ComponentVersionAdmin(ReadOnlyModelAdmin):
|
|
@@ -129,18 +125,6 @@ class ComponentVersionAdmin(ReadOnlyModelAdmin):
|
|
|
129
125
|
)
|
|
130
126
|
|
|
131
127
|
|
|
132
|
-
def link_for_cvc(cvc_obj: ComponentVersionContent) -> str:
|
|
133
|
-
"""
|
|
134
|
-
Get the download URL for the given ComponentVersionContent instance
|
|
135
|
-
"""
|
|
136
|
-
return "/media_server/component_asset/{}/{}/{}/{}".format(
|
|
137
|
-
cvc_obj.content.learning_package.key,
|
|
138
|
-
cvc_obj.component_version.component.key,
|
|
139
|
-
cvc_obj.component_version.version_num,
|
|
140
|
-
cvc_obj.key,
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
|
|
144
128
|
def format_text_for_admin_display(text: str) -> SafeText:
|
|
145
129
|
"""
|
|
146
130
|
Get the HTML to display the given plain text (preserving formatting)
|
|
@@ -158,9 +142,17 @@ def content_preview(cvc_obj: ComponentVersionContent) -> SafeText:
|
|
|
158
142
|
content_obj = cvc_obj.content
|
|
159
143
|
|
|
160
144
|
if content_obj.media_type.type == "image":
|
|
145
|
+
# This base64 encoding looks really goofy and is bad for performance,
|
|
146
|
+
# but image previews in the admin are extremely useful, and this lets us
|
|
147
|
+
# have them without creating a separate view in Learning Core. (Keep in
|
|
148
|
+
# mind that these assets are private, so they cannot be accessed via the
|
|
149
|
+
# MEDIA_URL like most Django uploaded assets.)
|
|
150
|
+
data = content_obj.read_file().read()
|
|
161
151
|
return format_html(
|
|
162
|
-
'<img src="{}" style="max-width: 100%;"
|
|
163
|
-
content_obj.
|
|
152
|
+
'<img src="data:{};base64, {}" style="max-width: 100%;" /><br><pre>{}</pre>',
|
|
153
|
+
content_obj.mime_type,
|
|
154
|
+
base64.encodebytes(data).decode('utf8'),
|
|
155
|
+
content_obj.os_path(),
|
|
164
156
|
)
|
|
165
157
|
|
|
166
158
|
return format_text_for_admin_display(
|
|
@@ -12,16 +12,18 @@ are stored in this app.
|
|
|
12
12
|
"""
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
-
from datetime import datetime
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
16
|
from enum import StrEnum, auto
|
|
17
17
|
from logging import getLogger
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from uuid import UUID
|
|
20
20
|
|
|
21
|
+
from django.core.exceptions import ValidationError
|
|
21
22
|
from django.db.models import Q, QuerySet
|
|
22
23
|
from django.db.transaction import atomic
|
|
23
24
|
from django.http.response import HttpResponse, HttpResponseNotFound
|
|
24
25
|
|
|
26
|
+
from ..collections.models import Collection, CollectionPublishableEntity
|
|
25
27
|
from ..contents import api as contents_api
|
|
26
28
|
from ..publishing import api as publishing_api
|
|
27
29
|
from .models import Component, ComponentType, ComponentVersion, ComponentVersionContent
|
|
@@ -48,6 +50,7 @@ __all__ = [
|
|
|
48
50
|
"look_up_component_version_content",
|
|
49
51
|
"AssetError",
|
|
50
52
|
"get_redirect_response_for_component_asset",
|
|
53
|
+
"set_collections",
|
|
51
54
|
]
|
|
52
55
|
|
|
53
56
|
|
|
@@ -269,7 +272,15 @@ def get_component_by_uuid(uuid: UUID) -> Component:
|
|
|
269
272
|
|
|
270
273
|
|
|
271
274
|
def get_component_version_by_uuid(uuid: UUID) -> ComponentVersion:
|
|
272
|
-
return
|
|
275
|
+
return (
|
|
276
|
+
ComponentVersion
|
|
277
|
+
.objects
|
|
278
|
+
.select_related(
|
|
279
|
+
"component",
|
|
280
|
+
"component__learning_package",
|
|
281
|
+
)
|
|
282
|
+
.get(publishable_entity_version__uuid=uuid)
|
|
283
|
+
)
|
|
273
284
|
|
|
274
285
|
|
|
275
286
|
def component_exists_by_key(
|
|
@@ -395,7 +406,21 @@ def create_component_version_content(
|
|
|
395
406
|
) -> ComponentVersionContent:
|
|
396
407
|
"""
|
|
397
408
|
Add a Content to the given ComponentVersion
|
|
409
|
+
|
|
410
|
+
We don't allow keys that would be absolute paths, e.g. ones that start with
|
|
411
|
+
'/'. Storing these causes headaches with building relative paths and because
|
|
412
|
+
of mismatches with things that expect a leading slash and those that don't.
|
|
413
|
+
So for safety and consistency, we strip off leading slashes and emit a
|
|
414
|
+
warning when we do.
|
|
398
415
|
"""
|
|
416
|
+
if key.startswith('/'):
|
|
417
|
+
logger.warning(
|
|
418
|
+
"Absolute paths are not supported: "
|
|
419
|
+
f"removed leading '/' from ComponentVersion {component_version_id} "
|
|
420
|
+
f"content key: {repr(key)} (content_id: {content_id})"
|
|
421
|
+
)
|
|
422
|
+
key = key.lstrip('/')
|
|
423
|
+
|
|
399
424
|
cvrc, _created = ComponentVersionContent.objects.get_or_create(
|
|
400
425
|
component_version_id=component_version_id,
|
|
401
426
|
content_id=content_id,
|
|
@@ -510,7 +535,12 @@ def get_redirect_response_for_component_asset(
|
|
|
510
535
|
|
|
511
536
|
# Check: Does the ComponentVersion exist?
|
|
512
537
|
try:
|
|
513
|
-
component_version =
|
|
538
|
+
component_version = (
|
|
539
|
+
ComponentVersion
|
|
540
|
+
.objects
|
|
541
|
+
.select_related("component", "component__learning_package")
|
|
542
|
+
.get(publishable_entity_version__uuid=component_version_uuid)
|
|
543
|
+
)
|
|
514
544
|
except ComponentVersion.DoesNotExist:
|
|
515
545
|
# No need to add headers here, because no ComponentVersion was found.
|
|
516
546
|
logger.error(f"Asset Not Found: No ComponentVersion with UUID {component_version_uuid}")
|
|
@@ -567,13 +597,63 @@ def get_redirect_response_for_component_asset(
|
|
|
567
597
|
# At this point, we know that there is valid Content that we want to send.
|
|
568
598
|
# This adds Content-level headers, like the hash/etag and content type.
|
|
569
599
|
info_headers.update(contents_api.get_content_info_headers(content))
|
|
570
|
-
stored_file_path = content.file_path()
|
|
571
600
|
|
|
572
601
|
# Recompute redirect headers (reminder: this should never be cached).
|
|
573
|
-
redirect_headers = contents_api.get_redirect_headers(
|
|
602
|
+
redirect_headers = contents_api.get_redirect_headers(content.path, public)
|
|
574
603
|
logger.info(
|
|
575
604
|
"Asset redirect (uncached metadata): "
|
|
576
605
|
f"{component_version_uuid}/{asset_path} -> {redirect_headers}"
|
|
577
606
|
)
|
|
578
607
|
|
|
579
608
|
return HttpResponse(headers={**info_headers, **redirect_headers})
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def set_collections(
|
|
612
|
+
learning_package_id: int,
|
|
613
|
+
component: Component,
|
|
614
|
+
collection_qset: QuerySet[Collection],
|
|
615
|
+
created_by: int | None = None,
|
|
616
|
+
) -> set[Collection]:
|
|
617
|
+
"""
|
|
618
|
+
Set collections for a given component.
|
|
619
|
+
|
|
620
|
+
These Collections must belong to the same LearningPackage as the Component, or a ValidationError will be raised.
|
|
621
|
+
|
|
622
|
+
Modified date of all collections related to component is updated.
|
|
623
|
+
|
|
624
|
+
Returns the updated collections.
|
|
625
|
+
"""
|
|
626
|
+
# Disallow adding entities outside the collection's learning package
|
|
627
|
+
invalid_collection = collection_qset.exclude(learning_package_id=learning_package_id).first()
|
|
628
|
+
if invalid_collection:
|
|
629
|
+
raise ValidationError(
|
|
630
|
+
f"Cannot add collection {invalid_collection.pk} in learning package "
|
|
631
|
+
f"{invalid_collection.learning_package_id} to component {component} in "
|
|
632
|
+
f"learning package {learning_package_id}."
|
|
633
|
+
)
|
|
634
|
+
current_relations = CollectionPublishableEntity.objects.filter(
|
|
635
|
+
entity=component.publishable_entity
|
|
636
|
+
).select_related('collection')
|
|
637
|
+
# Clear other collections for given component and add only new collections from collection_qset
|
|
638
|
+
removed_collections = set(
|
|
639
|
+
r.collection for r in current_relations.exclude(collection__in=collection_qset)
|
|
640
|
+
)
|
|
641
|
+
new_collections = set(collection_qset.exclude(
|
|
642
|
+
id__in=current_relations.values_list('collection', flat=True)
|
|
643
|
+
))
|
|
644
|
+
# Use `remove` instead of `CollectionPublishableEntity.delete()` to trigger m2m_changed signal which will handle
|
|
645
|
+
# updating component index.
|
|
646
|
+
component.publishable_entity.collections.remove(*removed_collections)
|
|
647
|
+
component.publishable_entity.collections.add(
|
|
648
|
+
*new_collections,
|
|
649
|
+
through_defaults={"created_by_id": created_by},
|
|
650
|
+
)
|
|
651
|
+
# Update modified date via update to avoid triggering post_save signal for collections
|
|
652
|
+
# The signal triggers index update for each collection synchronously which will be very slow in this case.
|
|
653
|
+
# Instead trigger the index update in the caller function asynchronously.
|
|
654
|
+
affected_collection = removed_collections | new_collections
|
|
655
|
+
Collection.objects.filter(
|
|
656
|
+
id__in=[collection.id for collection in affected_collection]
|
|
657
|
+
).update(modified=datetime.now(tz=timezone.utc))
|
|
658
|
+
|
|
659
|
+
return affected_collection
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Django admin for contents models
|
|
3
3
|
"""
|
|
4
|
+
import base64
|
|
5
|
+
|
|
4
6
|
from django.contrib import admin
|
|
5
7
|
from django.utils.html import format_html
|
|
6
8
|
|
|
@@ -16,7 +18,6 @@ class ContentAdmin(ReadOnlyModelAdmin):
|
|
|
16
18
|
"""
|
|
17
19
|
list_display = [
|
|
18
20
|
"hash_digest",
|
|
19
|
-
"file_link",
|
|
20
21
|
"learning_package",
|
|
21
22
|
"media_type",
|
|
22
23
|
"size",
|
|
@@ -29,24 +30,42 @@ class ContentAdmin(ReadOnlyModelAdmin):
|
|
|
29
30
|
"media_type",
|
|
30
31
|
"size",
|
|
31
32
|
"created",
|
|
32
|
-
"file_link",
|
|
33
|
-
"text_preview",
|
|
34
33
|
"has_file",
|
|
34
|
+
"path",
|
|
35
|
+
"os_path",
|
|
36
|
+
"text_preview",
|
|
37
|
+
"image_preview",
|
|
35
38
|
]
|
|
36
39
|
list_filter = ("media_type", "learning_package")
|
|
37
40
|
search_fields = ("hash_digest",)
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
@admin.display(description="OS Path")
|
|
43
|
+
def os_path(self, content: Content):
|
|
44
|
+
return content.os_path() or ""
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
content.file_url(),
|
|
46
|
-
)
|
|
46
|
+
def path(self, content: Content):
|
|
47
|
+
return content.path if content.has_file else ""
|
|
47
48
|
|
|
48
49
|
def text_preview(self, content: Content):
|
|
50
|
+
if not content.text:
|
|
51
|
+
return ""
|
|
49
52
|
return format_html(
|
|
50
53
|
'<pre style="white-space: pre-wrap;">\n{}\n</pre>',
|
|
51
54
|
content.text,
|
|
52
55
|
)
|
|
56
|
+
|
|
57
|
+
def image_preview(self, content: Content):
|
|
58
|
+
"""
|
|
59
|
+
Return HTML for an image, if that is the underlying Content.
|
|
60
|
+
|
|
61
|
+
Otherwise, just return a blank string.
|
|
62
|
+
"""
|
|
63
|
+
if content.media_type.type != "image":
|
|
64
|
+
return ""
|
|
65
|
+
|
|
66
|
+
data = content.read_file().read()
|
|
67
|
+
return format_html(
|
|
68
|
+
'<img src="data:{};base64, {}" style="max-width: 100%;" />',
|
|
69
|
+
content.mime_type,
|
|
70
|
+
base64.encodebytes(data).decode('utf8'),
|
|
71
|
+
)
|
|
@@ -7,11 +7,13 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
from functools import cache, cached_property
|
|
9
9
|
|
|
10
|
-
from django.
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
|
11
12
|
from django.core.files.base import File
|
|
12
|
-
from django.core.files.storage import Storage
|
|
13
|
+
from django.core.files.storage import Storage
|
|
13
14
|
from django.core.validators import MaxValueValidator
|
|
14
15
|
from django.db import models
|
|
16
|
+
from django.utils.module_loading import import_string
|
|
15
17
|
|
|
16
18
|
from ....lib.fields import MultiCollationTextField, case_insensitive_char_field, hash_field, manual_date_time_field
|
|
17
19
|
from ....lib.managers import WithRelationsManager
|
|
@@ -28,13 +30,25 @@ def get_storage() -> Storage:
|
|
|
28
30
|
"""
|
|
29
31
|
Return the Storage instance for our Content file persistence.
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
transition between Django 3.2 -> 4.2, where storage configuration has moved.
|
|
33
|
+
This will first search for an OPENEDX_LEARNING config dictionary and return
|
|
34
|
+
a Storage subclass based on that configuration.
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
If there is no value for the OPENEDX_LEARNING setting, we return the default
|
|
37
|
+
MEDIA storage class. TODO: Should we make it just error instead?
|
|
36
38
|
"""
|
|
37
|
-
|
|
39
|
+
config_dict = getattr(settings, 'OPENEDX_LEARNING', {})
|
|
40
|
+
|
|
41
|
+
if 'MEDIA' in config_dict:
|
|
42
|
+
storage_cls = import_string(config_dict['MEDIA']['BACKEND'])
|
|
43
|
+
options = config_dict['MEDIA'].get('OPTIONS', {})
|
|
44
|
+
return storage_cls(**options)
|
|
45
|
+
|
|
46
|
+
raise ImproperlyConfigured(
|
|
47
|
+
"Cannot access file storage: Missing the OPENEDX_LEARNING['MEDIA'] "
|
|
48
|
+
"setting, which should have a storage BACKEND and OPTIONS values for "
|
|
49
|
+
"a Storage subclass. These files should be stored in a location that "
|
|
50
|
+
"is NOT publicly accessible to browsers (so not in the MEDIA_ROOT)."
|
|
51
|
+
)
|
|
38
52
|
|
|
39
53
|
|
|
40
54
|
class MediaType(models.Model):
|
|
@@ -282,22 +296,53 @@ class Content(models.Model):
|
|
|
282
296
|
"""
|
|
283
297
|
return str(self.media_type)
|
|
284
298
|
|
|
285
|
-
|
|
299
|
+
@cached_property
|
|
300
|
+
def path(self):
|
|
301
|
+
"""
|
|
302
|
+
Logical path at which this content is stored (or would be stored).
|
|
303
|
+
|
|
304
|
+
This path is relative to OPENEDX_LEARNING['MEDIA'] configured storage
|
|
305
|
+
root. This file may not exist because has_file=False, or because we
|
|
306
|
+
haven't written the file yet (this is the method we call when trying to
|
|
307
|
+
figure out where the file *should* go).
|
|
308
|
+
"""
|
|
309
|
+
return f"content/{self.learning_package.uuid}/{self.hash_digest}"
|
|
310
|
+
|
|
311
|
+
def os_path(self):
|
|
312
|
+
"""
|
|
313
|
+
The full OS path for the underlying file for this Content.
|
|
314
|
+
|
|
315
|
+
This will not be supported by all Storage class types.
|
|
316
|
+
|
|
317
|
+
This will return ``None`` if there is no backing file (has_file=False).
|
|
286
318
|
"""
|
|
287
|
-
|
|
319
|
+
if self.has_file:
|
|
320
|
+
return get_storage().path(self.path)
|
|
321
|
+
return None
|
|
288
322
|
|
|
289
|
-
|
|
323
|
+
def read_file(self) -> File:
|
|
290
324
|
"""
|
|
291
|
-
|
|
325
|
+
Get a File object that has been open for reading.
|
|
326
|
+
|
|
327
|
+
We intentionally don't expose an `open()` call where callers can open
|
|
328
|
+
this file in write mode. Writing a Content file should happen at most
|
|
329
|
+
once, and the logic is not obvious (see ``write_file``).
|
|
330
|
+
|
|
331
|
+
At the end of the day, the caller can close the returned File and reopen
|
|
332
|
+
it in whatever mode they want, but we're trying to gently discourage
|
|
333
|
+
that kind of usage.
|
|
334
|
+
"""
|
|
335
|
+
return get_storage().open(self.path, 'rb')
|
|
292
336
|
|
|
293
337
|
def write_file(self, file: File) -> None:
|
|
294
338
|
"""
|
|
295
339
|
Write file contents to the file storage backend.
|
|
296
340
|
|
|
297
|
-
This function does nothing if the file already exists.
|
|
341
|
+
This function does nothing if the file already exists. Note that Content
|
|
342
|
+
is supposed to be immutable, so this should normally only be called once
|
|
343
|
+
for a given Content row.
|
|
298
344
|
"""
|
|
299
345
|
storage = get_storage()
|
|
300
|
-
file_path = self.file_path()
|
|
301
346
|
|
|
302
347
|
# There are two reasons why a file might already exist even if the the
|
|
303
348
|
# Content row is new:
|
|
@@ -314,15 +359,15 @@ class Content(models.Model):
|
|
|
314
359
|
# 3. Similar to (2), but only part of the file was written before an
|
|
315
360
|
# error occurred. This seems unlikely, but possible if the underlying
|
|
316
361
|
# storage engine writes in chunks.
|
|
317
|
-
if storage.exists(
|
|
362
|
+
if storage.exists(self.path) and storage.size(self.path) == file.size:
|
|
318
363
|
return
|
|
319
|
-
storage.save(
|
|
364
|
+
storage.save(self.path, file)
|
|
320
365
|
|
|
321
366
|
def file_url(self) -> str:
|
|
322
367
|
"""
|
|
323
368
|
This will sometimes be a time-limited signed URL.
|
|
324
369
|
"""
|
|
325
|
-
return
|
|
370
|
+
return get_storage().url(self.path)
|
|
326
371
|
|
|
327
372
|
def clean(self):
|
|
328
373
|
"""
|
|
@@ -361,7 +406,3 @@ class Content(models.Model):
|
|
|
361
406
|
]
|
|
362
407
|
verbose_name = "Content"
|
|
363
408
|
verbose_name_plural = "Contents"
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
def content_file_url(file_path):
|
|
367
|
-
return get_storage().url(file_path)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: openedx-learning
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.0
|
|
4
4
|
Summary: Open edX Learning Core and Tagging.
|
|
5
5
|
Home-page: https://github.com/openedx/openedx-learning
|
|
6
6
|
Author: David Ormsbee
|
|
@@ -18,12 +18,12 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
19
|
Requires-Python: >=3.11
|
|
20
20
|
License-File: LICENSE.txt
|
|
21
|
-
Requires-Dist: attrs
|
|
22
|
-
Requires-Dist: Django<5.0
|
|
23
|
-
Requires-Dist: djangorestframework<4.0
|
|
24
21
|
Requires-Dist: edx-drf-extensions
|
|
25
22
|
Requires-Dist: celery
|
|
23
|
+
Requires-Dist: attrs
|
|
24
|
+
Requires-Dist: Django<5.0
|
|
26
25
|
Requires-Dist: rules<4.0
|
|
26
|
+
Requires-Dist: djangorestframework<4.0
|
|
27
27
|
|
|
28
28
|
Open edX Learning Core (and Tagging)
|
|
29
29
|
====================================
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
openedx_learning/__init__.py,sha256=
|
|
1
|
+
openedx_learning/__init__.py,sha256=fJlzpA8ZDMYQSqZnlzD-fmJITs1x7c9Xb6Nk13d6cVE,68
|
|
2
2
|
openedx_learning/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
3
|
openedx_learning/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
openedx_learning/api/authoring.py,sha256=vbRpiQ2wOfN3oR2bbN0-bI2ra0QRtha9tVixKW1ENis,929
|
|
@@ -17,8 +17,8 @@ openedx_learning/apps/authoring/collections/migrations/0004_collection_key.py,sh
|
|
|
17
17
|
openedx_learning/apps/authoring/collections/migrations/0005_alter_collection_options_alter_collection_enabled.py,sha256=HdU_3zxN32nzzvOFpiVpQXleHleJhnq2d8k7jAxhUTM,504
|
|
18
18
|
openedx_learning/apps/authoring/collections/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
19
|
openedx_learning/apps/authoring/components/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
-
openedx_learning/apps/authoring/components/admin.py,sha256=
|
|
21
|
-
openedx_learning/apps/authoring/components/api.py,sha256=
|
|
20
|
+
openedx_learning/apps/authoring/components/admin.py,sha256=3kFu_PR0BFb8U0zVv3WWhi27i__TDuJG0pFlwr3tKAw,4614
|
|
21
|
+
openedx_learning/apps/authoring/components/api.py,sha256=TCLFCPb7ScoRWhe7YpQ7hBiJGjoa47OmazQWcN2hIwU,25759
|
|
22
22
|
openedx_learning/apps/authoring/components/apps.py,sha256=YoYPsI9gcleA3uEs8CiLIrjUncRMo2DKbYt4mDfzePg,770
|
|
23
23
|
openedx_learning/apps/authoring/components/models.py,sha256=T-wc7vxaWMlulQmMsVH7m6Pd857P3Eguo0vtflTLURI,13415
|
|
24
24
|
openedx_learning/apps/authoring/components/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -28,10 +28,10 @@ openedx_learning/apps/authoring/components/migrations/0001_initial.py,sha256=446
|
|
|
28
28
|
openedx_learning/apps/authoring/components/migrations/0002_alter_componentversioncontent_key.py,sha256=98724dtucRjJCRyLt5p45qXYb2d6-ouVGp7PB6zTG6E,539
|
|
29
29
|
openedx_learning/apps/authoring/components/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
30
30
|
openedx_learning/apps/authoring/contents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
31
|
-
openedx_learning/apps/authoring/contents/admin.py,sha256=
|
|
31
|
+
openedx_learning/apps/authoring/contents/admin.py,sha256=9Njd_lje1emcd168KBWUTGf0mVJ6K-dMYMcqHNjRU4k,1761
|
|
32
32
|
openedx_learning/apps/authoring/contents/api.py,sha256=bXb9yQjPfoP1Ynf1aAYz3BEPffK7H5cnba6KdPFSiG0,8818
|
|
33
33
|
openedx_learning/apps/authoring/contents/apps.py,sha256=EEUZEnww7TcYcyxMovZthG2muNxd7j7nxBIf21gKrp4,398
|
|
34
|
-
openedx_learning/apps/authoring/contents/models.py,sha256=
|
|
34
|
+
openedx_learning/apps/authoring/contents/models.py,sha256=nv6T0SXHJovs0FeAtED1iPg_HbkJF5vz2N6WyzYXS6Q,17720
|
|
35
35
|
openedx_learning/apps/authoring/contents/migrations/0001_initial.py,sha256=FtOTmIGX2KHpjw-PHbfRjxkFEomI5CEDhNKCZ7IpFeE,3060
|
|
36
36
|
openedx_learning/apps/authoring/contents/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
37
37
|
openedx_learning/apps/authoring/publishing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -68,7 +68,7 @@ openedx_tagging/core/tagging/rules.py,sha256=Gzw2RCQxoAv2PpOwOWgpD17XoZfowlFnNgQ
|
|
|
68
68
|
openedx_tagging/core/tagging/urls.py,sha256=-0Nzmh0mlF9-GgEuocwBdSJn6n8EINnxR4m4pmPou44,159
|
|
69
69
|
openedx_tagging/core/tagging/import_export/__init__.py,sha256=q5K4JalFQlJxAFUFyqhLY5zQtAskDnRM1H_aVuP_E3Q,83
|
|
70
70
|
openedx_tagging/core/tagging/import_export/actions.py,sha256=n007-M59D0mXYcwi9CDldDDy5JHAaGCVv9dQbiY4pZ4,13275
|
|
71
|
-
openedx_tagging/core/tagging/import_export/api.py,sha256=
|
|
71
|
+
openedx_tagging/core/tagging/import_export/api.py,sha256=cZj3F7_nzLQi73RHURW1YoTWB8K_aXk6GqKvnRcdmbc,7162
|
|
72
72
|
openedx_tagging/core/tagging/import_export/exceptions.py,sha256=GGBldoW0tjYBrSlqDDwKkl6N0FIg1Yt5xcl7efxfo20,3116
|
|
73
73
|
openedx_tagging/core/tagging/import_export/import_plan.py,sha256=ol9mLfqqR0t1q0Om7D-iC9zKw1EI9MX3yAEKL8q6ias,6824
|
|
74
74
|
openedx_tagging/core/tagging/import_export/parsers.py,sha256=eXjMPfMfqcCUKSaXc5xfPEI5PFEK1tNJNGnKHz51tY8,9864
|
|
@@ -110,8 +110,8 @@ openedx_tagging/core/tagging/rest_api/v1/serializers.py,sha256=gbvEBLvsmfPc3swWz
|
|
|
110
110
|
openedx_tagging/core/tagging/rest_api/v1/urls.py,sha256=dNUKCtUCx_YzrwlbEbpDfjGVQbb2QdJ1VuJCkladj6E,752
|
|
111
111
|
openedx_tagging/core/tagging/rest_api/v1/views.py,sha256=ZRkSILdb8g5k_BcuuVVfdffEdY9vFQ_YtMa3JrN0Xz8,35581
|
|
112
112
|
openedx_tagging/core/tagging/rest_api/v1/views_import.py,sha256=kbHUPe5A6WaaJ3J1lFIcYCt876ecLNQfd19m7YYub6c,1470
|
|
113
|
-
openedx_learning-0.
|
|
114
|
-
openedx_learning-0.
|
|
115
|
-
openedx_learning-0.
|
|
116
|
-
openedx_learning-0.
|
|
117
|
-
openedx_learning-0.
|
|
113
|
+
openedx_learning-0.15.0.dist-info/LICENSE.txt,sha256=QTW2QN7q3XszgUAXm9Dzgtu5LXYKbR1SGnqMa7ufEuY,35139
|
|
114
|
+
openedx_learning-0.15.0.dist-info/METADATA,sha256=6cN09KcBqzdTVsxN7te-_yMVFRCPHldYO9rE81dtEWI,8777
|
|
115
|
+
openedx_learning-0.15.0.dist-info/WHEEL,sha256=AHX6tWk3qWuce7vKLrj7lnulVHEdWoltgauo8bgCXgU,109
|
|
116
|
+
openedx_learning-0.15.0.dist-info/top_level.txt,sha256=IYFbr5mgiEHd-LOtZmXj3q3a0bkGK1M9LY7GXgnfi4M,33
|
|
117
|
+
openedx_learning-0.15.0.dist-info/RECORD,,
|
|
@@ -151,7 +151,7 @@ def import_tags(
|
|
|
151
151
|
task.end_success(global_elapsed_time)
|
|
152
152
|
|
|
153
153
|
return True, task, tag_import_plan
|
|
154
|
-
except Exception as exception:
|
|
154
|
+
except Exception as exception: # pylint: disable=broad-exception-caught
|
|
155
155
|
# Log any exception
|
|
156
156
|
task.log_exception(exception)
|
|
157
157
|
return False, task, None
|
|
File without changes
|
|
File without changes
|