openedx-learning 0.11.4__py2.py3-none-any.whl → 0.12.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/api.py +200 -2
- openedx_learning/apps/authoring/components/management/__init__.py +0 -0
- openedx_learning/apps/authoring/components/management/commands/__init__.py +0 -0
- openedx_learning/apps/authoring/components/management/commands/add_assets_to_component.py +111 -0
- openedx_learning/apps/authoring/contents/api.py +69 -0
- openedx_learning/apps/authoring/contents/models.py +20 -4
- openedx_learning/apps/authoring/publishing/model_mixins.py +14 -0
- {openedx_learning-0.11.4.dist-info → openedx_learning-0.12.0.dist-info}/METADATA +4 -4
- {openedx_learning-0.11.4.dist-info → openedx_learning-0.12.0.dist-info}/RECORD +13 -10
- {openedx_learning-0.11.4.dist-info → openedx_learning-0.12.0.dist-info}/WHEEL +1 -1
- {openedx_learning-0.11.4.dist-info → openedx_learning-0.12.0.dist-info}/LICENSE.txt +0 -0
- {openedx_learning-0.11.4.dist-info → openedx_learning-0.12.0.dist-info}/top_level.txt +0 -0
openedx_learning/__init__.py
CHANGED
|
@@ -13,11 +13,16 @@ are stored in this app.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
from datetime import datetime
|
|
16
|
+
from enum import StrEnum, auto
|
|
17
|
+
from logging import getLogger
|
|
16
18
|
from pathlib import Path
|
|
19
|
+
from uuid import UUID
|
|
17
20
|
|
|
18
21
|
from django.db.models import Q, QuerySet
|
|
19
22
|
from django.db.transaction import atomic
|
|
23
|
+
from django.http.response import HttpResponse, HttpResponseNotFound
|
|
20
24
|
|
|
25
|
+
from ..contents import api as contents_api
|
|
21
26
|
from ..publishing import api as publishing_api
|
|
22
27
|
from .models import Component, ComponentType, ComponentVersion, ComponentVersionContent
|
|
23
28
|
|
|
@@ -34,12 +39,20 @@ __all__ = [
|
|
|
34
39
|
"create_component_and_version",
|
|
35
40
|
"get_component",
|
|
36
41
|
"get_component_by_key",
|
|
42
|
+
"get_component_by_uuid",
|
|
43
|
+
"get_component_version_by_uuid",
|
|
37
44
|
"component_exists_by_key",
|
|
38
45
|
"get_components",
|
|
39
46
|
"create_component_version_content",
|
|
47
|
+
"look_up_component_version_content",
|
|
48
|
+
"AssetError",
|
|
49
|
+
"get_redirect_response_for_component_asset",
|
|
40
50
|
]
|
|
41
51
|
|
|
42
52
|
|
|
53
|
+
logger = getLogger()
|
|
54
|
+
|
|
55
|
+
|
|
43
56
|
def get_or_create_component_type(namespace: str, name: str) -> ComponentType:
|
|
44
57
|
"""
|
|
45
58
|
Get the ID of a ComponentType, and create if missing.
|
|
@@ -112,9 +125,9 @@ def create_component_version(
|
|
|
112
125
|
def create_next_component_version(
|
|
113
126
|
component_pk: int,
|
|
114
127
|
/,
|
|
115
|
-
title: str,
|
|
116
128
|
content_to_replace: dict[str, int | None],
|
|
117
129
|
created: datetime,
|
|
130
|
+
title: str | None = None,
|
|
118
131
|
created_by: int | None = None,
|
|
119
132
|
) -> ComponentVersion:
|
|
120
133
|
"""
|
|
@@ -150,8 +163,11 @@ def create_next_component_version(
|
|
|
150
163
|
last_version = component.versioning.latest
|
|
151
164
|
if last_version is None:
|
|
152
165
|
next_version_num = 1
|
|
166
|
+
title = title or ""
|
|
153
167
|
else:
|
|
154
168
|
next_version_num = last_version.version_num + 1
|
|
169
|
+
if title is None:
|
|
170
|
+
title = last_version.title
|
|
155
171
|
|
|
156
172
|
with atomic():
|
|
157
173
|
publishable_entity_version = publishing_api.create_publishable_entity_version(
|
|
@@ -247,6 +263,14 @@ def get_component_by_key(
|
|
|
247
263
|
)
|
|
248
264
|
|
|
249
265
|
|
|
266
|
+
def get_component_by_uuid(uuid: UUID) -> Component:
|
|
267
|
+
return Component.with_publishing_relations.get(publishable_entity__uuid=uuid)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_component_version_by_uuid(uuid: UUID) -> ComponentVersion:
|
|
271
|
+
return ComponentVersion.objects.get(publishable_entity_version__uuid=uuid)
|
|
272
|
+
|
|
273
|
+
|
|
250
274
|
def component_exists_by_key(
|
|
251
275
|
learning_package_id: int,
|
|
252
276
|
/,
|
|
@@ -351,7 +375,7 @@ def create_component_version_content(
|
|
|
351
375
|
content_id: int,
|
|
352
376
|
/,
|
|
353
377
|
key: str,
|
|
354
|
-
learner_downloadable=False,
|
|
378
|
+
learner_downloadable: bool = False,
|
|
355
379
|
) -> ComponentVersionContent:
|
|
356
380
|
"""
|
|
357
381
|
Add a Content to the given ComponentVersion
|
|
@@ -363,3 +387,177 @@ def create_component_version_content(
|
|
|
363
387
|
learner_downloadable=learner_downloadable,
|
|
364
388
|
)
|
|
365
389
|
return cvrc
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class AssetError(StrEnum):
|
|
393
|
+
"""Error codes related to fetching ComponentVersion assets."""
|
|
394
|
+
ASSET_PATH_NOT_FOUND_FOR_COMPONENT_VERSION = auto()
|
|
395
|
+
ASSET_NOT_LEARNER_DOWNLOADABLE = auto()
|
|
396
|
+
ASSET_HAS_NO_DOWNLOAD_FILE = auto()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _get_component_version_info_headers(component_version: ComponentVersion) -> dict[str, str]:
|
|
400
|
+
"""
|
|
401
|
+
These are the headers we can derive based on a valid ComponentVersion.
|
|
402
|
+
|
|
403
|
+
These headers are intended to ease development and debugging, by showing
|
|
404
|
+
where this static asset is coming from. These headers will work even if
|
|
405
|
+
the asset path does not exist for this particular ComponentVersion.
|
|
406
|
+
"""
|
|
407
|
+
component = component_version.component
|
|
408
|
+
learning_package = component.learning_package
|
|
409
|
+
return {
|
|
410
|
+
# Component
|
|
411
|
+
"X-Open-edX-Component-Key": component.publishable_entity.key,
|
|
412
|
+
"X-Open-edX-Component-Uuid": component.uuid,
|
|
413
|
+
# Component Version
|
|
414
|
+
"X-Open-edX-Component-Version-Uuid": component_version.uuid,
|
|
415
|
+
"X-Open-edX-Component-Version-Num": component_version.version_num,
|
|
416
|
+
# Learning Package
|
|
417
|
+
"X-Open-edX-Learning-Package-Key": learning_package.key,
|
|
418
|
+
"X-Open-edX-Learning-Package-Uuid": learning_package.uuid,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def get_redirect_response_for_component_asset(
|
|
423
|
+
component_version_uuid: UUID,
|
|
424
|
+
asset_path: Path,
|
|
425
|
+
public: bool = False,
|
|
426
|
+
learner_downloadable_only: bool = True,
|
|
427
|
+
) -> HttpResponse:
|
|
428
|
+
"""
|
|
429
|
+
``HttpResponse`` for a reverse-proxy to serve a ``ComponentVersion`` asset.
|
|
430
|
+
|
|
431
|
+
:param component_version_uuid: ``UUID`` of the ``ComponentVersion`` that the
|
|
432
|
+
asset is part of.
|
|
433
|
+
|
|
434
|
+
:param asset_path: Path to the asset being requested.
|
|
435
|
+
|
|
436
|
+
:param public: Is this asset going to be made available without auth checks?
|
|
437
|
+
If ``True``, this will return an ``HttpResponse`` that can be cached in
|
|
438
|
+
a CDN and shared across many clients.
|
|
439
|
+
|
|
440
|
+
:param learner_downloadable_only: Only return assets that are meant to be
|
|
441
|
+
downloadable by Learners, i.e. in the LMS experience. If this is
|
|
442
|
+
``True``, then requests for assets that are not meant for student
|
|
443
|
+
download will return a ``404`` error response.
|
|
444
|
+
|
|
445
|
+
**Response Codes**
|
|
446
|
+
|
|
447
|
+
If the asset exists for this ``ComponentVersion``, this function will return
|
|
448
|
+
an ``HttpResponse`` with a status code of ``200``.
|
|
449
|
+
|
|
450
|
+
If the specified asset does not exist for this ``ComponentVersion``, or if
|
|
451
|
+
the ``ComponentVersion`` itself does not exist, the response code will be
|
|
452
|
+
``404``.
|
|
453
|
+
|
|
454
|
+
Other than checking the coarse-grained ``learner_downloadable_only`` flag,
|
|
455
|
+
*this function does not do auth checking of any sort*–it will never return
|
|
456
|
+
a ``401`` or ``403`` response code. That is by design. Figuring out who is
|
|
457
|
+
making the request and whether they have permission to do so is the
|
|
458
|
+
responsiblity of whatever is calling this function. The
|
|
459
|
+
``learner_downloadable_only`` flag is intended to be a filter for the entire
|
|
460
|
+
view. When it's True, not even staff can download component-internal assets.
|
|
461
|
+
This is intended to protect us from accidentally allowing sensitive grading
|
|
462
|
+
code to get leaked out.
|
|
463
|
+
|
|
464
|
+
**Metadata Headers**
|
|
465
|
+
|
|
466
|
+
The ``HttpResponse`` returned by this function will have headers describing
|
|
467
|
+
the asset and the ``ComponentVersion`` it belongs to (if it exists):
|
|
468
|
+
|
|
469
|
+
* ``Content-Type``
|
|
470
|
+
* ``Etag`` (this will be the asset's hash digest)
|
|
471
|
+
* ``X-Open-edX-Component-Key``
|
|
472
|
+
* ``X-Open-edX-Component-Uuid``
|
|
473
|
+
* ``X-Open-edX-Component-Version-Uuid``
|
|
474
|
+
* ``X-Open-edX-Component-Version-Num``
|
|
475
|
+
* ``X-Open-edX-Learning-Package-Key``
|
|
476
|
+
* ``X-Open-edX-Learning-Package-Uuid``
|
|
477
|
+
|
|
478
|
+
**Asset Redirection**
|
|
479
|
+
|
|
480
|
+
For performance reasons, the ``HttpResponse`` object returned by this
|
|
481
|
+
function does not contain the actual content data of the asset. It requires
|
|
482
|
+
an appropriately configured reverse proxy server that handles the
|
|
483
|
+
``X-Accel-Redirect`` header (both Caddy and Nginx support this).
|
|
484
|
+
|
|
485
|
+
.. warning::
|
|
486
|
+
If you add any headers here, you may need to add them in the "media"
|
|
487
|
+
service container's reverse proxy configuration. In Tutor, this is a
|
|
488
|
+
Caddyfile. All non-standard HTTP headers should be prefixed with
|
|
489
|
+
``X-Open-edX-``.
|
|
490
|
+
"""
|
|
491
|
+
# Helper to generate error header messages.
|
|
492
|
+
def _error_header(error: AssetError) -> dict[str, str]:
|
|
493
|
+
return {"X-Open-edX-Error": str(error)}
|
|
494
|
+
|
|
495
|
+
# Check: Does the ComponentVersion exist?
|
|
496
|
+
try:
|
|
497
|
+
component_version = get_component_version_by_uuid(component_version_uuid)
|
|
498
|
+
except ComponentVersion.DoesNotExist:
|
|
499
|
+
# No need to add headers here, because no ComponentVersion was found.
|
|
500
|
+
logger.error(f"Asset Not Found: No ComponentVersion with UUID {component_version_uuid}")
|
|
501
|
+
return HttpResponseNotFound()
|
|
502
|
+
|
|
503
|
+
# At this point we know that the ComponentVersion exists, so we can build
|
|
504
|
+
# those headers...
|
|
505
|
+
info_headers = _get_component_version_info_headers(component_version)
|
|
506
|
+
|
|
507
|
+
# Check: Does the ComponentVersion have the requested asset (Content)?
|
|
508
|
+
try:
|
|
509
|
+
cv_content = component_version.componentversioncontent_set.get(key=asset_path)
|
|
510
|
+
except ComponentVersionContent.DoesNotExist:
|
|
511
|
+
logger.error(f"ComponentVersion {component_version_uuid} has no asset {asset_path}")
|
|
512
|
+
info_headers.update(
|
|
513
|
+
_error_header(AssetError.ASSET_PATH_NOT_FOUND_FOR_COMPONENT_VERSION)
|
|
514
|
+
)
|
|
515
|
+
return HttpResponseNotFound(headers=info_headers)
|
|
516
|
+
|
|
517
|
+
# Check: Does the Content have a downloadable file, instead of just inline
|
|
518
|
+
# text? It's easy for us to grab this content and stream it to the user
|
|
519
|
+
# anyway, but we're explicitly not doing so because streaming large text
|
|
520
|
+
# fields from the database is less scalable, and we don't want to encourage
|
|
521
|
+
# that usage pattern.
|
|
522
|
+
content = cv_content.content
|
|
523
|
+
if not content.has_file:
|
|
524
|
+
logger.error(
|
|
525
|
+
f"ComponentVersion {component_version_uuid} has asset {asset_path}, "
|
|
526
|
+
"but it is not downloadable (has_file=False)."
|
|
527
|
+
)
|
|
528
|
+
info_headers.update(
|
|
529
|
+
_error_header(AssetError.ASSET_HAS_NO_DOWNLOAD_FILE)
|
|
530
|
+
)
|
|
531
|
+
return HttpResponseNotFound(headers=info_headers)
|
|
532
|
+
|
|
533
|
+
# Check: If we're asking only for Learner Downloadable assets, and the asset
|
|
534
|
+
# in question is not supposed to be downloadable by learners, then we give a
|
|
535
|
+
# 404 error. Even staff members are not expected to be able to download
|
|
536
|
+
# these assets via the LMS endpoint that serves students. Studio would be
|
|
537
|
+
# expected to have an entirely different view to serve these assets in that
|
|
538
|
+
# context (along with different timeouts, auth, and cache settings). So in
|
|
539
|
+
# that sense, the asset doesn't exist for that particular endpoint.
|
|
540
|
+
if learner_downloadable_only and (not cv_content.learner_downloadable):
|
|
541
|
+
logger.error(
|
|
542
|
+
f"ComponentVersion {component_version_uuid} has asset {asset_path}, "
|
|
543
|
+
"but it is not meant to be downloadable by learners "
|
|
544
|
+
"(ComponentVersionContent.learner_downloadable=False)."
|
|
545
|
+
)
|
|
546
|
+
info_headers.update(
|
|
547
|
+
_error_header(AssetError.ASSET_NOT_LEARNER_DOWNLOADABLE)
|
|
548
|
+
)
|
|
549
|
+
return HttpResponseNotFound(headers=info_headers)
|
|
550
|
+
|
|
551
|
+
# At this point, we know that there is valid Content that we want to send.
|
|
552
|
+
# This adds Content-level headers, like the hash/etag and content type.
|
|
553
|
+
info_headers.update(contents_api.get_content_info_headers(content))
|
|
554
|
+
stored_file_path = content.file_path()
|
|
555
|
+
|
|
556
|
+
# Recompute redirect headers (reminder: this should never be cached).
|
|
557
|
+
redirect_headers = contents_api.get_redirect_headers(stored_file_path, public)
|
|
558
|
+
logger.info(
|
|
559
|
+
"Asset redirect (uncached metadata): "
|
|
560
|
+
f"{component_version_uuid}/{asset_path} -> {redirect_headers}"
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
return HttpResponse(headers={**info_headers, **redirect_headers})
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Management command to add files to a Component.
|
|
3
|
+
|
|
4
|
+
This is mostly meant to be a debugging tool to let us to easily load some test
|
|
5
|
+
asset data into the system.
|
|
6
|
+
"""
|
|
7
|
+
import mimetypes
|
|
8
|
+
import pathlib
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from django.core.management.base import BaseCommand
|
|
12
|
+
|
|
13
|
+
from ....components.api import create_component_version_content
|
|
14
|
+
from ....contents.api import get_or_create_file_content, get_or_create_media_type
|
|
15
|
+
from ....publishing.api import get_learning_package_by_key
|
|
16
|
+
from ...api import create_next_component_version, get_component_by_key
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Command(BaseCommand):
|
|
20
|
+
"""
|
|
21
|
+
Add files to a Component, creating a new Component Version.
|
|
22
|
+
|
|
23
|
+
This does not publish the the Component.
|
|
24
|
+
|
|
25
|
+
Note: This is a quick debug tool meant to stuff some asset data into
|
|
26
|
+
Learning Core models for testing. It's not intended as a robust and
|
|
27
|
+
performant tool for modifying actual production content, and should not be
|
|
28
|
+
used for that purpose.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def add_arguments(self, parser):
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"learning_package_key",
|
|
34
|
+
type=str,
|
|
35
|
+
help="LearningPackage.key value for where the Component is located."
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"component_key",
|
|
39
|
+
type=str,
|
|
40
|
+
help="Component.key that you want to add assets to."
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"file_mappings",
|
|
44
|
+
nargs="+",
|
|
45
|
+
type=str,
|
|
46
|
+
help=(
|
|
47
|
+
"Mappings of desired Component asset paths to the disk paths "
|
|
48
|
+
"of where to upload the file from, separated by ':'. (Example: "
|
|
49
|
+
"static/donkey.jpg:/Users/dave/Desktop/donkey-big.jpg). A "
|
|
50
|
+
"blank value for upload file means to remove that from the "
|
|
51
|
+
"Component. You may upload/remove as many files as you want in "
|
|
52
|
+
"a single invocation."
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def handle(self, *args, **options):
|
|
57
|
+
"""
|
|
58
|
+
Add files to a Component as ComponentVersion -> Content associations.
|
|
59
|
+
"""
|
|
60
|
+
learning_package_key = options["learning_package_key"]
|
|
61
|
+
component_key = options["component_key"]
|
|
62
|
+
file_mappings = options["file_mappings"]
|
|
63
|
+
|
|
64
|
+
learning_package = get_learning_package_by_key(learning_package_key)
|
|
65
|
+
# Parse something like: "xblock.v1:problem:area_of_circle_1"
|
|
66
|
+
namespace, type_name, local_key = component_key.split(":", 2)
|
|
67
|
+
component = get_component_by_key(
|
|
68
|
+
learning_package.id, namespace, type_name, local_key
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
created = datetime.now(tz=timezone.utc)
|
|
72
|
+
keys_to_remove = set()
|
|
73
|
+
local_keys_to_content = {}
|
|
74
|
+
|
|
75
|
+
for file_mapping in file_mappings:
|
|
76
|
+
local_key, file_path = file_mapping.split(":", 1)
|
|
77
|
+
|
|
78
|
+
# No file_path means to delete this entry from the next version.
|
|
79
|
+
if not file_path:
|
|
80
|
+
keys_to_remove.add(local_key)
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
media_type_str, _encoding = mimetypes.guess_type(file_path)
|
|
84
|
+
media_type = get_or_create_media_type(media_type_str)
|
|
85
|
+
content = get_or_create_file_content(
|
|
86
|
+
learning_package.id,
|
|
87
|
+
media_type.id,
|
|
88
|
+
data=pathlib.Path(file_path).read_bytes(),
|
|
89
|
+
created=created,
|
|
90
|
+
)
|
|
91
|
+
local_keys_to_content[local_key] = content.id
|
|
92
|
+
|
|
93
|
+
next_version = create_next_component_version(
|
|
94
|
+
component.pk,
|
|
95
|
+
content_to_replace={local_key: None for local_key in keys_to_remove},
|
|
96
|
+
created=created,
|
|
97
|
+
)
|
|
98
|
+
for local_key, content_id in sorted(local_keys_to_content.items()):
|
|
99
|
+
create_component_version_content(
|
|
100
|
+
next_version.pk,
|
|
101
|
+
content_id,
|
|
102
|
+
key=local_key,
|
|
103
|
+
learner_downloadable=True,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
self.stdout.write(
|
|
107
|
+
f"Created v{next_version.version_num} of "
|
|
108
|
+
f"{next_version.component.key} ({next_version.uuid}):"
|
|
109
|
+
)
|
|
110
|
+
for cvc in next_version.componentversioncontent_set.all():
|
|
111
|
+
self.stdout.write(f"- {cvc.key} ({cvc.uuid})")
|
|
@@ -7,6 +7,7 @@ are stored in this app.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
from datetime import datetime
|
|
10
|
+
from logging import getLogger
|
|
10
11
|
|
|
11
12
|
from django.core.files.base import ContentFile
|
|
12
13
|
from django.db.transaction import atomic
|
|
@@ -22,11 +23,15 @@ from .models import Content, MediaType
|
|
|
22
23
|
__all__ = [
|
|
23
24
|
"get_or_create_media_type",
|
|
24
25
|
"get_content",
|
|
26
|
+
"get_content_info_headers",
|
|
25
27
|
"get_or_create_text_content",
|
|
26
28
|
"get_or_create_file_content",
|
|
27
29
|
]
|
|
28
30
|
|
|
29
31
|
|
|
32
|
+
log = getLogger()
|
|
33
|
+
|
|
34
|
+
|
|
30
35
|
def get_or_create_media_type(mime_type: str) -> MediaType:
|
|
31
36
|
"""
|
|
32
37
|
Return the MediaType.id for the desired mime_type string.
|
|
@@ -168,3 +173,67 @@ def get_or_create_file_content(
|
|
|
168
173
|
content.write_file(ContentFile(data))
|
|
169
174
|
|
|
170
175
|
return content
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_content_info_headers(content: Content) -> dict[str, str]:
|
|
179
|
+
"""
|
|
180
|
+
Return HTTP headers that are specific to this Content.
|
|
181
|
+
|
|
182
|
+
This currently only consists of the Content-Type and ETag. These values are
|
|
183
|
+
safe to cache.
|
|
184
|
+
"""
|
|
185
|
+
return {
|
|
186
|
+
"Content-Type": str(content.media_type),
|
|
187
|
+
"Etag": content.hash_digest,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_redirect_headers(
|
|
192
|
+
stored_file_path: str,
|
|
193
|
+
public: bool = False,
|
|
194
|
+
max_age: int | None = None,
|
|
195
|
+
) -> dict[str, str]:
|
|
196
|
+
"""
|
|
197
|
+
Return a dict of headers for file redirect and caching.
|
|
198
|
+
|
|
199
|
+
This is a separate function from get_content_info_headers because the URLs
|
|
200
|
+
returned in these headers produced by this function should never be put into
|
|
201
|
+
the backend Django cache (redis/memcached). The `stored_file_path` location
|
|
202
|
+
*is* cacheable though–that's the actual storage location for the resource,
|
|
203
|
+
and not a link that could potentially expire.
|
|
204
|
+
|
|
205
|
+
TODO: We need to add support for short-lived URL generation from the
|
|
206
|
+
stored_file_path.
|
|
207
|
+
"""
|
|
208
|
+
if public:
|
|
209
|
+
# If an asset is public, then let it be cached by the reverse-proxy and
|
|
210
|
+
# CDN, but do require that it be revalidated after the suggested max
|
|
211
|
+
# age. This would help us do things like take a URL that was mistakenly
|
|
212
|
+
# made public and make it require authentication. Fortunately, checking
|
|
213
|
+
# that the content is up to date is a cheap operation, since it just
|
|
214
|
+
# requires examining the Etag.
|
|
215
|
+
cache_directive = "must-revalidate"
|
|
216
|
+
|
|
217
|
+
# Default to an hour of caching, to make it easier to tighten access
|
|
218
|
+
# later on.
|
|
219
|
+
max_age = max_age or (5 * 60)
|
|
220
|
+
else:
|
|
221
|
+
# If an asset is meant to be private, that means this response should
|
|
222
|
+
# not be cached by either the reverse-proxy or any CDN–it's only ever
|
|
223
|
+
# cached on the user's browser. This is what you'd use for very granular
|
|
224
|
+
# permissions checking, e.g. "only let them see this image if they have
|
|
225
|
+
# access to the Component it's associated with". Note that we're not
|
|
226
|
+
# doing ``Vary: Cookie`` because that would fill the reverse-proxy and
|
|
227
|
+
# CDN caches with a lot of redundant entries.
|
|
228
|
+
cache_directive = "private"
|
|
229
|
+
|
|
230
|
+
# This only stays on the user's browser, so cache for a whole day. This
|
|
231
|
+
# is okay to do because Content data is typically immutable–i.e. if an
|
|
232
|
+
# asset actually changes, the user should be directed to a different URL
|
|
233
|
+
# for it.
|
|
234
|
+
max_age = max_age or (60 * 60 * 24)
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"Cache-Control": f"max-age={max_age}, {cache_directive}",
|
|
238
|
+
"X-Accel-Redirect": stored_file_path,
|
|
239
|
+
}
|
|
@@ -5,7 +5,7 @@ more intelligent data models to be useful.
|
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
from functools import cached_property
|
|
8
|
+
from functools import cache, cached_property
|
|
9
9
|
|
|
10
10
|
from django.core.exceptions import ValidationError
|
|
11
11
|
from django.core.files.base import File
|
|
@@ -23,6 +23,7 @@ __all__ = [
|
|
|
23
23
|
]
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
@cache
|
|
26
27
|
def get_storage() -> Storage:
|
|
27
28
|
"""
|
|
28
29
|
Return the Storage instance for our Content file persistence.
|
|
@@ -236,6 +237,10 @@ class Content(models.Model):
|
|
|
236
237
|
hash_digest = hash_field()
|
|
237
238
|
|
|
238
239
|
# Do we have file data stored for this Content in our file storage backend?
|
|
240
|
+
# We use has_file instead of a FileField because it's more space efficient.
|
|
241
|
+
# The location of a Content's file data is derivable from the Learning
|
|
242
|
+
# Package's UUID and the hash of the Content. There's no need to waste that
|
|
243
|
+
# space to encode it in every row.
|
|
239
244
|
has_file = models.BooleanField()
|
|
240
245
|
|
|
241
246
|
# The ``text`` field contains the text representation of the Content, if
|
|
@@ -288,6 +293,8 @@ class Content(models.Model):
|
|
|
288
293
|
def write_file(self, file: File) -> None:
|
|
289
294
|
"""
|
|
290
295
|
Write file contents to the file storage backend.
|
|
296
|
+
|
|
297
|
+
This function does nothing if the file already exists.
|
|
291
298
|
"""
|
|
292
299
|
storage = get_storage()
|
|
293
300
|
file_path = self.file_path()
|
|
@@ -303,14 +310,19 @@ class Content(models.Model):
|
|
|
303
310
|
# be two logically separate Content entries if they are different file
|
|
304
311
|
# types. This lets other models add data to Content via 1:1 relations by
|
|
305
312
|
# ContentType (e.g. all SRT files). This is definitely an edge case.
|
|
306
|
-
|
|
307
|
-
|
|
313
|
+
#
|
|
314
|
+
# 3. Similar to (2), but only part of the file was written before an
|
|
315
|
+
# error occurred. This seems unlikely, but possible if the underlying
|
|
316
|
+
# storage engine writes in chunks.
|
|
317
|
+
if storage.exists(file_path) and storage.size(file_path) == file.size:
|
|
318
|
+
return
|
|
319
|
+
storage.save(file_path, file)
|
|
308
320
|
|
|
309
321
|
def file_url(self) -> str:
|
|
310
322
|
"""
|
|
311
323
|
This will sometimes be a time-limited signed URL.
|
|
312
324
|
"""
|
|
313
|
-
return
|
|
325
|
+
return content_file_url(self.file_path())
|
|
314
326
|
|
|
315
327
|
def clean(self):
|
|
316
328
|
"""
|
|
@@ -349,3 +361,7 @@ class Content(models.Model):
|
|
|
349
361
|
]
|
|
350
362
|
verbose_name = "Content"
|
|
351
363
|
verbose_name_plural = "Contents"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def content_file_url(file_path):
|
|
367
|
+
return get_storage().url(file_path)
|
|
@@ -60,6 +60,10 @@ class PublishableEntityMixin(models.Model):
|
|
|
60
60
|
def created(self):
|
|
61
61
|
return self.publishable_entity.created
|
|
62
62
|
|
|
63
|
+
@property
|
|
64
|
+
def created_by(self):
|
|
65
|
+
return self.publishable_entity.created_by
|
|
66
|
+
|
|
63
67
|
class Meta:
|
|
64
68
|
abstract = True
|
|
65
69
|
|
|
@@ -268,6 +272,16 @@ class PublishableEntityMixin(models.Model):
|
|
|
268
272
|
publishable_entity_version__entity_id=pub_ent.id
|
|
269
273
|
)
|
|
270
274
|
|
|
275
|
+
def version_num(self, version_num):
|
|
276
|
+
"""
|
|
277
|
+
Return a specific numbered version model.
|
|
278
|
+
"""
|
|
279
|
+
pub_ent = self.content_obj.publishable_entity
|
|
280
|
+
return self.content_version_model_cls.objects.get(
|
|
281
|
+
publishable_entity_version__entity_id=pub_ent.id,
|
|
282
|
+
publishable_entity_version__version_num=version_num,
|
|
283
|
+
)
|
|
284
|
+
|
|
271
285
|
|
|
272
286
|
class PublishableEntityVersionMixin(models.Model):
|
|
273
287
|
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: openedx-learning
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.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: edx-drf-extensions
|
|
22
21
|
Requires-Dist: celery
|
|
23
|
-
Requires-Dist: rules<4.0
|
|
24
|
-
Requires-Dist: attrs
|
|
25
22
|
Requires-Dist: Django<5.0
|
|
26
23
|
Requires-Dist: djangorestframework<4.0
|
|
24
|
+
Requires-Dist: rules<4.0
|
|
25
|
+
Requires-Dist: edx-drf-extensions
|
|
26
|
+
Requires-Dist: attrs
|
|
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=A3F2eIt1aRYsY0WQV1upYl-XM-eKJqIdLO55kEcvNuc,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
|
|
@@ -16,24 +16,27 @@ openedx_learning/apps/authoring/collections/migrations/0004_collection_key.py,sh
|
|
|
16
16
|
openedx_learning/apps/authoring/collections/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
17
|
openedx_learning/apps/authoring/components/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
18
|
openedx_learning/apps/authoring/components/admin.py,sha256=QE7e76C6X2V1AQPxQe6SayQPfCMmbs4RZPEPIGcvTWw,4672
|
|
19
|
-
openedx_learning/apps/authoring/components/api.py,sha256=
|
|
19
|
+
openedx_learning/apps/authoring/components/api.py,sha256=FXYUUL6Q-MdsH78WKr9yRftJ9YcP5YfGDlJIIUT2zeA,21878
|
|
20
20
|
openedx_learning/apps/authoring/components/apps.py,sha256=YoYPsI9gcleA3uEs8CiLIrjUncRMo2DKbYt4mDfzePg,770
|
|
21
21
|
openedx_learning/apps/authoring/components/models.py,sha256=T-wc7vxaWMlulQmMsVH7m6Pd857P3Eguo0vtflTLURI,13415
|
|
22
|
+
openedx_learning/apps/authoring/components/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
openedx_learning/apps/authoring/components/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
openedx_learning/apps/authoring/components/management/commands/add_assets_to_component.py,sha256=v7mJfnu25M8w4-U1lPPn575W7lYAaOapcHEAZVaxil4,4142
|
|
22
25
|
openedx_learning/apps/authoring/components/migrations/0001_initial.py,sha256=446LkJSFeK8J_-l-bxakZ_BVx_CiJIllGcBYqWcEenA,4664
|
|
23
26
|
openedx_learning/apps/authoring/components/migrations/0002_alter_componentversioncontent_key.py,sha256=98724dtucRjJCRyLt5p45qXYb2d6-ouVGp7PB6zTG6E,539
|
|
24
27
|
openedx_learning/apps/authoring/components/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
28
|
openedx_learning/apps/authoring/contents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
29
|
openedx_learning/apps/authoring/contents/admin.py,sha256=4ILH_cEiAXKUlfPVwJJZeh5yupgF3v7kf-xJ6ZTTFDE,1174
|
|
27
|
-
openedx_learning/apps/authoring/contents/api.py,sha256=
|
|
30
|
+
openedx_learning/apps/authoring/contents/api.py,sha256=bXb9yQjPfoP1Ynf1aAYz3BEPffK7H5cnba6KdPFSiG0,8818
|
|
28
31
|
openedx_learning/apps/authoring/contents/apps.py,sha256=EEUZEnww7TcYcyxMovZthG2muNxd7j7nxBIf21gKrp4,398
|
|
29
|
-
openedx_learning/apps/authoring/contents/models.py,sha256=
|
|
32
|
+
openedx_learning/apps/authoring/contents/models.py,sha256=aunf5ai8ssfyqof5-5dyH_nDR5J3X0d3wQLMaETUWCs,15878
|
|
30
33
|
openedx_learning/apps/authoring/contents/migrations/0001_initial.py,sha256=FtOTmIGX2KHpjw-PHbfRjxkFEomI5CEDhNKCZ7IpFeE,3060
|
|
31
34
|
openedx_learning/apps/authoring/contents/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
32
35
|
openedx_learning/apps/authoring/publishing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
33
36
|
openedx_learning/apps/authoring/publishing/admin.py,sha256=F-0QlVQmuovLIF258XK_vKJdOnn7lLa_0A5veE72TKc,4830
|
|
34
37
|
openedx_learning/apps/authoring/publishing/api.py,sha256=rC3dg52pK9Pfx1qKzqOEbV5wj1fL3vcLHsoj1jy5vto,16818
|
|
35
38
|
openedx_learning/apps/authoring/publishing/apps.py,sha256=jUfd78xvXaZg3dwkqXihatbeajJGm3Uz1rJpuyd-3g0,402
|
|
36
|
-
openedx_learning/apps/authoring/publishing/model_mixins.py,sha256=
|
|
39
|
+
openedx_learning/apps/authoring/publishing/model_mixins.py,sha256=DGM6cZSNX4KZHy5xWtNy6yO4bRT04VDus8yZJ4v3vI0,14447
|
|
37
40
|
openedx_learning/apps/authoring/publishing/models.py,sha256=ImMAujPDc2-CECZw_yvVlUOdtGYwmt99TJ2r1HJkkV8,20488
|
|
38
41
|
openedx_learning/apps/authoring/publishing/migrations/0001_initial.py,sha256=wvekNV19YRSdxRmQaFnLSn_nCsQlHIucPDVMmgKf_OE,9272
|
|
39
42
|
openedx_learning/apps/authoring/publishing/migrations/0002_alter_learningpackage_key_and_more.py,sha256=toI7qJhNukk6hirKfFx9EpqTpzF2O2Yq1VpFJusDn2M,806
|
|
@@ -105,8 +108,8 @@ openedx_tagging/core/tagging/rest_api/v1/serializers.py,sha256=gbvEBLvsmfPc3swWz
|
|
|
105
108
|
openedx_tagging/core/tagging/rest_api/v1/urls.py,sha256=dNUKCtUCx_YzrwlbEbpDfjGVQbb2QdJ1VuJCkladj6E,752
|
|
106
109
|
openedx_tagging/core/tagging/rest_api/v1/views.py,sha256=ZRkSILdb8g5k_BcuuVVfdffEdY9vFQ_YtMa3JrN0Xz8,35581
|
|
107
110
|
openedx_tagging/core/tagging/rest_api/v1/views_import.py,sha256=kbHUPe5A6WaaJ3J1lFIcYCt876ecLNQfd19m7YYub6c,1470
|
|
108
|
-
openedx_learning-0.
|
|
109
|
-
openedx_learning-0.
|
|
110
|
-
openedx_learning-0.
|
|
111
|
-
openedx_learning-0.
|
|
112
|
-
openedx_learning-0.
|
|
111
|
+
openedx_learning-0.12.0.dist-info/LICENSE.txt,sha256=QTW2QN7q3XszgUAXm9Dzgtu5LXYKbR1SGnqMa7ufEuY,35139
|
|
112
|
+
openedx_learning-0.12.0.dist-info/METADATA,sha256=QwEYCY9OeCr03fC0GgdLy7eEpptZEaVFx1KcR8JoYeI,8777
|
|
113
|
+
openedx_learning-0.12.0.dist-info/WHEEL,sha256=muXAwoPanksrVvf9Mcykr8l6Q0JyBrGUVYr50kE4bxo,109
|
|
114
|
+
openedx_learning-0.12.0.dist-info/top_level.txt,sha256=IYFbr5mgiEHd-LOtZmXj3q3a0bkGK1M9LY7GXgnfi4M,33
|
|
115
|
+
openedx_learning-0.12.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|