forked from openedx/edx-platform
-
Notifications
You must be signed in to change notification settings - Fork 7
/
api.py
1382 lines (1148 loc) · 49.8 KB
/
api.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Python API for content libraries
================================
Via ``views.py``, most of these API methods are also exposed as a REST API.
The API methods in this file are focused on authoring and specific to content
libraries; they wouldn't necessarily apply or work in other learning contexts
such as courses, blogs, "pathways," etc.
** As this is an authoring-focused API, all API methods in this file deal with
the DRAFT version of the content library.**
Some of these methods will work and may be used from the LMS if needed (mostly
for test setup; other use is discouraged), but some of the implementation
details rely on Studio so other methods will raise errors if called from the
LMS. (The REST API is not available at all from the LMS.)
Any APIs that use/affect content libraries but are generic enough to work in
other learning contexts too are in the core XBlock python/REST API at
``openedx.core.djangoapps.xblock.api/rest_api``.
For example, to render a content library XBlock as HTML, one can use the
generic:
render_block_view(block, view_name, user)
That is an API in ``openedx.core.djangoapps.xblock.api`` (use it from Studio for
the draft version, from the LMS for published version).
There are one or two methods in this file that have some overlap with the core
XBlock API; for example, this content library API provides a
``get_library_block()`` which returns metadata about an XBlock; it's in this API
because it also returns data about whether or not the XBlock has unpublished
edits, which is an authoring-only concern. Likewise, APIs for getting/setting
an individual XBlock's OLX directly seem more appropriate for small, reusable
components in content libraries and may not be appropriate for other learning
contexts so they are implemented here in the library API only. In the future,
if we find a need for these in most other learning contexts then those methods
could be promoted to the core XBlock API and made generic.
Import from Courseware
----------------------
Content Libraries can import blocks from Courseware (Modulestore). The import
can be done per-course, by listing its content, and supports both access to
remote platform instances as well as local modulestore APIs. Additionally,
there are Celery-based interfaces suitable for background processing controlled
through RESTful APIs (see :mod:`.views`).
"""
from __future__ import annotations
import abc
import collections
from datetime import datetime, timezone
import base64
import hashlib
import logging
import attr
import requests
from django.conf import settings
from django.contrib.auth.models import AbstractUser, Group
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.core.validators import validate_unicode_slug
from django.db import IntegrityError, transaction
from django.db.models import Q, QuerySet
from django.utils.translation import gettext as _
from edx_rest_api_client.client import OAuthAPIClient
from lxml import etree
from opaque_keys.edx.keys import UsageKey, UsageKeyV2
from opaque_keys.edx.locator import (
LibraryLocatorV2,
LibraryUsageLocatorV2,
LibraryLocator as LibraryLocatorV1
)
from opaque_keys import InvalidKeyError
from openedx_events.content_authoring.data import ContentLibraryData, LibraryBlockData
from openedx_events.content_authoring.signals import (
CONTENT_LIBRARY_CREATED,
CONTENT_LIBRARY_DELETED,
CONTENT_LIBRARY_UPDATED,
LIBRARY_BLOCK_CREATED,
LIBRARY_BLOCK_DELETED,
LIBRARY_BLOCK_UPDATED,
)
from openedx_learning.api import authoring as authoring_api
from openedx_learning.api.authoring_models import Component, MediaType
from organizations.models import Organization
from xblock.core import XBlock
from xblock.exceptions import XBlockNotFoundError
from openedx.core.djangoapps.xblock.api import get_component_from_usage_key, xblock_type_display_name
from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core
from xmodule.library_root_xblock import LibraryRoot as LibraryRootV1
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from . import permissions, tasks
from .constants import ALL_RIGHTS_RESERVED, COMPLEX
from .models import ContentLibrary, ContentLibraryPermission, ContentLibraryBlockImportTask
log = logging.getLogger(__name__)
# Exceptions
# ==========
ContentLibraryNotFound = ContentLibrary.DoesNotExist
class ContentLibraryBlockNotFound(XBlockNotFoundError):
""" XBlock not found in the content library """
class LibraryAlreadyExists(KeyError):
""" A library with the specified slug already exists """
class LibraryBlockAlreadyExists(KeyError):
""" An XBlock with that ID already exists in the library """
class BlockLimitReachedError(Exception):
""" Maximum number of allowed XBlocks in the library reached """
class IncompatibleTypesError(Exception):
""" Library type constraint violated """
class InvalidNameError(ValueError):
""" The specified name/identifier is not valid """
class LibraryPermissionIntegrityError(IntegrityError):
""" Thrown when an operation would cause insane permissions. """
# Models
# ======
@attr.s
class ContentLibraryMetadata:
"""
Class that represents the metadata about a content library.
"""
key = attr.ib(type=LibraryLocatorV2)
title = attr.ib("")
description = attr.ib("")
num_blocks = attr.ib(0)
version = attr.ib(0)
type = attr.ib(default=COMPLEX)
last_published = attr.ib(default=None, type=datetime)
last_draft_created = attr.ib(default=None, type=datetime)
last_draft_created_by = attr.ib(default=None, type=datetime)
published_by = attr.ib("")
has_unpublished_changes = attr.ib(False)
# has_unpublished_deletes will be true when the draft version of the library's bundle
# contains deletes of any XBlocks that were in the most recently published version
has_unpublished_deletes = attr.ib(False)
allow_lti = attr.ib(False)
# Allow any user (even unregistered users) to view and interact directly
# with this library's content in the LMS
allow_public_learning = attr.ib(False)
# Allow any user with Studio access to view this library's content in
# Studio, use it in their courses, and copy content out of this library.
allow_public_read = attr.ib(False)
license = attr.ib("")
created = attr.ib(default=None, type=datetime)
updated = attr.ib(default=None, type=datetime)
class AccessLevel:
""" Enum defining library access levels/permissions """
ADMIN_LEVEL = ContentLibraryPermission.ADMIN_LEVEL
AUTHOR_LEVEL = ContentLibraryPermission.AUTHOR_LEVEL
READ_LEVEL = ContentLibraryPermission.READ_LEVEL
NO_ACCESS = None
@attr.s
class ContentLibraryPermissionEntry:
"""
A user or group granted permission to use a content library.
"""
user = attr.ib(type=AbstractUser, default=None)
group = attr.ib(type=Group, default=None)
access_level = attr.ib(AccessLevel.NO_ACCESS)
@attr.s
class LibraryXBlockMetadata:
"""
Class that represents the metadata about an XBlock in a content library.
"""
usage_key = attr.ib(type=LibraryUsageLocatorV2)
created = attr.ib(type=datetime)
modified = attr.ib(type=datetime)
display_name = attr.ib("")
last_published = attr.ib(default=None, type=datetime)
has_unpublished_changes = attr.ib(False)
tags_count = attr.ib(0)
@classmethod
def from_component(cls, library_key, component):
"""
Construct a LibraryXBlockMetadata from a Component object.
"""
last_publish_log = component.versioning.last_publish_log
return cls(
usage_key=LibraryUsageLocatorV2(
library_key,
component.component_type.name,
component.local_key,
),
display_name=component.versioning.draft.title,
created=component.created,
modified=component.versioning.draft.created,
last_published=None if last_publish_log is None else last_publish_log.published_at,
has_unpublished_changes=component.versioning.has_unpublished_changes
)
@attr.s
class LibraryXBlockStaticFile:
"""
Class that represents a static file in a content library, associated with
a particular XBlock.
"""
# File path e.g. "diagram.png"
# In some rare cases it might contain a folder part, e.g. "en/track1.srt"
path = attr.ib("")
# Publicly accessible URL where the file can be downloaded
url = attr.ib("")
# Size in bytes
size = attr.ib(0)
@attr.s
class LibraryXBlockType:
"""
An XBlock type that can be added to a content library
"""
block_type = attr.ib("")
display_name = attr.ib("")
# General APIs
# ============
def get_libraries_for_user(user, org=None, library_type=None, text_search=None, order=None):
"""
Return content libraries that the user has permission to view.
"""
filter_kwargs = {}
if org:
filter_kwargs['org__short_name'] = org
if library_type:
filter_kwargs['type'] = library_type
qs = ContentLibrary.objects.filter(**filter_kwargs) \
.select_related('learning_package', 'org') \
.order_by('org__short_name', 'slug')
if text_search:
qs = qs.filter(
Q(slug__icontains=text_search) |
Q(org__short_name__icontains=text_search) |
Q(learning_package__title__icontains=text_search) |
Q(learning_package__description__icontains=text_search)
)
filtered = permissions.perms[permissions.CAN_VIEW_THIS_CONTENT_LIBRARY].filter(user, qs)
if order:
order_query = 'learning_package__'
valid_order_fields = ['title', 'created', 'updated']
# If order starts with a -, that means order descending (default is ascending)
if order.startswith('-'):
order_query = f"-{order_query}"
order = order[1:]
if order in valid_order_fields:
return filtered.order_by(f"{order_query}{order}")
else:
log.exception(f"Error ordering libraries by {order}: Invalid order field")
return filtered
def get_metadata(queryset, text_search=None):
"""
Take a list of ContentLibrary objects and return metadata from Learning Core.
"""
if text_search:
queryset = queryset.filter(org__short_name__icontains=text_search)
libraries = [
# TODO: Do we really need these fields for the library listing view?
# It's actually going to be pretty expensive to compute this over a
# large list. If we do need it, it might need to go into a denormalized
# form, e.g. a new table for stats that it can join to, even if we don't
# guarantee accuracy (because of possible race conditions).
ContentLibraryMetadata(
key=lib.library_key,
title=lib.learning_package.title if lib.learning_package else "",
type=lib.type,
description="",
version=0,
allow_public_learning=lib.allow_public_learning,
allow_public_read=lib.allow_public_read,
# These are currently dummy values to maintain the REST API contract
# while we shift to Learning Core models.
num_blocks=0,
last_published=None,
has_unpublished_changes=False,
has_unpublished_deletes=False,
license=lib.license,
)
for lib in queryset
]
return libraries
def require_permission_for_library_key(library_key, user, permission):
"""
Given any of the content library permission strings defined in
openedx.core.djangoapps.content_libraries.permissions,
check if the given user has that permission for the library with the
specified library ID.
Raises django.core.exceptions.PermissionDenied if the user doesn't have
permission.
"""
library_obj = ContentLibrary.objects.get_by_key(library_key)
if not user.has_perm(permission, obj=library_obj):
raise PermissionDenied
def get_library(library_key):
"""
Get the library with the specified key. Does not check permissions.
returns a ContentLibraryMetadata instance.
Raises ContentLibraryNotFound if the library doesn't exist.
"""
ref = ContentLibrary.objects.get_by_key(library_key)
learning_package = ref.learning_package
num_blocks = authoring_api.get_all_drafts(learning_package.id).count()
last_publish_log = authoring_api.get_last_publish(learning_package.id)
last_draft_log = authoring_api.get_entities_with_unpublished_changes(learning_package.id) \
.order_by('-created').first()
last_draft_created = last_draft_log.created if last_draft_log else None
last_draft_created_by = last_draft_log.created_by.username if last_draft_log and last_draft_log.created_by else None
has_unpublished_changes = last_draft_log is not None
# TODO: I'm doing this one to match already-existing behavior, but this is
# something that we should remove. It exists to accomodate some complexities
# with how Blockstore staged changes, but Learning Core works differently,
# and has_unpublished_changes should be sufficient.
# Ref: https://github.com/openedx/edx-platform/issues/34283
has_unpublished_deletes = authoring_api.get_entities_with_unpublished_deletes(learning_package.id) \
.exists()
# Learning Core doesn't really have a notion of a global version number,but
# we can sort of approximate it by using the primary key of the last publish
# log entry, in the sense that it will be a monotonically increasing
# integer, though there will be large gaps. We use 0 to denote that nothing
# has been done, since that will never be a valid value for a PublishLog pk.
#
# That being said, we should figure out if we really even want to keep a top
# level version indicator for the Library as a whole. In the v1 libs
# implemention, this served as a way to know whether or not there was an
# updated version of content that a course could pull in. But more recently,
# we've decided to do those version references at the level of the
# individual blocks being used, since a Learning Core backed library is
# intended to be used for many LibraryContentBlocks and not 1:1 like v1
# libraries. The top level version stays for now because LibraryContentBlock
# uses it, but that should hopefully change before the Redwood release.
version = 0 if last_publish_log is None else last_publish_log.pk
published_by = None
if last_publish_log and last_publish_log.published_by:
published_by = last_publish_log.published_by.username
return ContentLibraryMetadata(
key=library_key,
title=learning_package.title,
type=ref.type,
description=ref.learning_package.description,
num_blocks=num_blocks,
version=version,
last_published=None if last_publish_log is None else last_publish_log.published_at,
published_by=published_by,
last_draft_created=last_draft_created,
last_draft_created_by=last_draft_created_by,
allow_lti=ref.allow_lti,
allow_public_learning=ref.allow_public_learning,
allow_public_read=ref.allow_public_read,
has_unpublished_changes=has_unpublished_changes,
has_unpublished_deletes=has_unpublished_deletes,
license=ref.license,
created=learning_package.created,
updated=learning_package.updated,
)
def create_library(
org,
slug,
title,
description="",
allow_public_learning=False,
allow_public_read=False,
library_license=ALL_RIGHTS_RESERVED,
library_type=COMPLEX,
):
"""
Create a new content library.
org: an organizations.models.Organization instance
slug: a slug for this library like 'physics-problems'
title: title for this library
description: description of this library
allow_public_learning: Allow anyone to read/learn from blocks in the LMS
allow_public_read: Allow anyone to view blocks (including source) in Studio?
library_type: Deprecated parameter, not really used. Set to COMPLEX.
Returns a ContentLibraryMetadata instance.
"""
assert isinstance(org, Organization)
validate_unicode_slug(slug)
try:
with transaction.atomic():
ref = ContentLibrary.objects.create(
org=org,
slug=slug,
type=library_type,
allow_public_learning=allow_public_learning,
allow_public_read=allow_public_read,
license=library_license,
)
learning_package = authoring_api.create_learning_package(
key=str(ref.library_key),
title=title,
description=description,
)
ref.learning_package = learning_package
ref.save()
except IntegrityError:
raise LibraryAlreadyExists(slug) # lint-amnesty, pylint: disable=raise-missing-from
CONTENT_LIBRARY_CREATED.send_event(
content_library=ContentLibraryData(
library_key=ref.library_key
)
)
return ContentLibraryMetadata(
key=ref.library_key,
title=title,
type=library_type,
description=description,
num_blocks=0,
version=0,
last_published=None,
allow_public_learning=ref.allow_public_learning,
allow_public_read=ref.allow_public_read,
license=library_license,
)
def get_library_team(library_key):
"""
Get the list of users/groups granted permission to use this library.
"""
ref = ContentLibrary.objects.get_by_key(library_key)
return [
ContentLibraryPermissionEntry(user=entry.user, group=entry.group, access_level=entry.access_level)
for entry in ref.permission_grants.all()
]
def get_library_user_permissions(library_key, user):
"""
Fetch the specified user's access information. Will return None if no
permissions have been granted.
"""
ref = ContentLibrary.objects.get_by_key(library_key)
grant = ref.permission_grants.filter(user=user).first()
if grant is None:
return None
return ContentLibraryPermissionEntry(
user=grant.user,
group=grant.group,
access_level=grant.access_level,
)
def set_library_user_permissions(library_key, user, access_level):
"""
Change the specified user's level of access to this library.
access_level should be one of the AccessLevel values defined above.
"""
ref = ContentLibrary.objects.get_by_key(library_key)
current_grant = get_library_user_permissions(library_key, user)
if current_grant and current_grant.access_level == AccessLevel.ADMIN_LEVEL:
if not ref.permission_grants.filter(access_level=AccessLevel.ADMIN_LEVEL).exclude(user_id=user.id).exists():
raise LibraryPermissionIntegrityError(_('Cannot change or remove the access level for the only admin.'))
if access_level is None:
ref.permission_grants.filter(user=user).delete()
else:
ContentLibraryPermission.objects.update_or_create(
library=ref,
user=user,
defaults={"access_level": access_level},
)
def set_library_group_permissions(library_key, group, access_level):
"""
Change the specified group's level of access to this library.
access_level should be one of the AccessLevel values defined above.
"""
ref = ContentLibrary.objects.get_by_key(library_key)
if access_level is None:
ref.permission_grants.filter(group=group).delete()
else:
ContentLibraryPermission.objects.update_or_create(
library=ref,
group=group,
defaults={"access_level": access_level},
)
def update_library(
library_key,
title=None,
description=None,
allow_public_learning=None,
allow_public_read=None,
library_type=None,
library_license=None,
):
"""
Update a library's metadata
(Slug cannot be changed as it would break IDs throughout the system.)
A value of None means "don't change".
"""
lib_obj_fields = [
allow_public_learning, allow_public_read, library_type, library_license
]
lib_obj_changed = any(field is not None for field in lib_obj_fields)
learning_pkg_changed = any(field is not None for field in [title, description])
# If nothing's changed, just return early.
if (not lib_obj_changed) and (not learning_pkg_changed):
return
content_lib = ContentLibrary.objects.get_by_key(library_key)
with transaction.atomic():
# We need to make updates to both the ContentLibrary and its linked
# LearningPackage.
if lib_obj_changed:
if allow_public_learning is not None:
content_lib.allow_public_learning = allow_public_learning
if allow_public_read is not None:
content_lib.allow_public_read = allow_public_read
if library_type is not None:
# TODO: Get rid of this field entirely, and remove library_type
# from any functions that take it as an argument.
content_lib.library_type = library_type
if library_license is not None:
content_lib.library_license = library_license
content_lib.save()
if learning_pkg_changed:
authoring_api.update_learning_package(
content_lib.learning_package_id,
title=title,
description=description,
)
CONTENT_LIBRARY_UPDATED.send_event(
content_library=ContentLibraryData(
library_key=content_lib.library_key
)
)
return content_lib
def delete_library(library_key):
"""
Delete a content library
"""
with transaction.atomic():
content_lib = ContentLibrary.objects.get_by_key(library_key)
learning_package = content_lib.learning_package
content_lib.delete()
# TODO: Move the LearningPackage delete() operation to an API call
# TODO: We should eventually detach the LearningPackage and delete it
# asynchronously, especially if we need to delete a bunch of stuff
# on the filesystem for it.
learning_package.delete()
CONTENT_LIBRARY_DELETED.send_event(
content_library=ContentLibraryData(
library_key=library_key
)
)
def _get_library_component_tags_count(library_key) -> dict:
"""
Get the count of tags that are applied to each component in this library, as a dict.
"""
# Import content_tagging.api here to avoid circular imports
from openedx.core.djangoapps.content_tagging.api import get_object_tag_counts
# Create a pattern to match the IDs of the library components, e.g. "lb:org:id*"
library_key_pattern = str(library_key).replace("lib:", "lb:", 1) + "*"
return get_object_tag_counts(library_key_pattern, count_implicit=True)
def get_library_components(library_key, text_search=None, block_types=None) -> QuerySet[Component]:
"""
Get the library components and filter.
TODO: Full text search needs to be implemented as a custom lookup for MySQL,
but it should have a fallback to still work in SQLite.
"""
lib = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined]
learning_package = lib.learning_package
components = authoring_api.get_components(
learning_package.id,
draft=True,
namespace='xblock.v1',
type_names=block_types,
draft_title=text_search,
)
return components
def get_library_block(usage_key) -> LibraryXBlockMetadata:
"""
Get metadata about (the draft version of) one specific XBlock in a library.
This will raise ContentLibraryBlockNotFound if there is no draft version of
this block (i.e. it's been soft-deleted from Studio), even if there is a
live published version of it in the LMS.
"""
try:
component = get_component_from_usage_key(usage_key)
except ObjectDoesNotExist as exc:
raise ContentLibraryBlockNotFound(usage_key) from exc
# The component might have existed at one point, but no longer does because
# the draft was soft-deleted. This is actually a weird edge case and I'm not
# clear on what the proper behavior should be, since (a) the published
# version still exists; and (b) we might want to make some queries on the
# block even after it's been removed, since there might be versioned
# references to it.
draft_version = component.versioning.draft
if not draft_version:
raise ContentLibraryBlockNotFound(usage_key)
xblock_metadata = LibraryXBlockMetadata.from_component(
library_key=usage_key.context_key,
component=component,
)
return xblock_metadata
def set_library_block_olx(usage_key, new_olx_str):
"""
Replace the OLX source of the given XBlock.
This is only meant for use by developers or API client applications, as
very little validation is done and this can easily result in a broken XBlock
that won't load.
"""
# because this old pylint can't understand attr.ib() objects, pylint: disable=no-member
assert isinstance(usage_key, LibraryUsageLocatorV2)
# Make sure the block exists:
_block_metadata = get_library_block(usage_key)
# Verify that the OLX parses, at least as generic XML, and the root tag is correct:
node = etree.fromstring(new_olx_str)
if node.tag != usage_key.block_type:
raise ValueError(
f"Tried to set the OLX of a {usage_key.block_type} block to a <{node.tag}> node. "
f"{usage_key=!s}, {new_olx_str=}"
)
# We're intentionally NOT checking if the XBlock type is installed, since
# this is one of the only tools you can reach for to edit content for an
# XBlock that's broken or missing.
component = get_component_from_usage_key(usage_key)
# Get the title from the new OLX (or default to the default specified on the
# XBlock's display_name field.
new_title = node.attrib.get(
"display_name",
xblock_type_display_name(usage_key.block_type),
)
now = datetime.now(tz=timezone.utc)
with transaction.atomic():
new_content = authoring_api.get_or_create_text_content(
component.learning_package_id,
get_or_create_olx_media_type(usage_key.block_type).id,
text=new_olx_str,
created=now,
)
authoring_api.create_next_version(
component.pk,
title=new_title,
content_to_replace={
'block.xml': new_content.pk,
},
created=now,
)
LIBRARY_BLOCK_UPDATED.send_event(
library_block=LibraryBlockData(
library_key=usage_key.context_key,
usage_key=usage_key
)
)
def create_library_block(library_key, block_type, definition_id, user_id=None):
"""
Create a new XBlock in this library of the specified type (e.g. "html").
"""
# It's in the serializer as ``definition_id``, but for our purposes, it's
# the block_id. See the comments in ``LibraryXBlockCreationSerializer`` for
# more details. TODO: Change the param name once we change the serializer.
block_id = definition_id
assert isinstance(library_key, LibraryLocatorV2)
ref = ContentLibrary.objects.get_by_key(library_key)
if ref.type != COMPLEX:
if block_type != ref.type:
raise IncompatibleTypesError(
_('Block type "{block_type}" is not compatible with library type "{library_type}".').format(
block_type=block_type, library_type=ref.type,
)
)
# If adding a component would take us over our max, return an error.
component_count = authoring_api.get_all_drafts(ref.learning_package.id).count()
if component_count + 1 > settings.MAX_BLOCKS_PER_CONTENT_LIBRARY:
raise BlockLimitReachedError(
_("Library cannot have more than {} Components").format(
settings.MAX_BLOCKS_PER_CONTENT_LIBRARY
)
)
# Make sure the proposed ID will be valid:
validate_unicode_slug(block_id)
# Ensure the XBlock type is valid and installed:
XBlock.load_class(block_type) # Will raise an exception if invalid
# Make sure the new ID is not taken already:
usage_key = LibraryUsageLocatorV2(
lib_key=library_key,
block_type=block_type,
usage_id=block_id,
)
if _component_exists(usage_key):
raise LibraryBlockAlreadyExists(f"An XBlock with ID '{usage_key}' already exists")
_create_component_for_block(ref, usage_key, user_id=user_id)
# Now return the metadata about the new block:
LIBRARY_BLOCK_CREATED.send_event(
library_block=LibraryBlockData(
library_key=ref.library_key,
usage_key=usage_key
)
)
return get_library_block(usage_key)
def _component_exists(usage_key: UsageKeyV2) -> bool:
"""
Does a Component exist for this usage key?
This is a lower-level function that will return True if a Component object
exists, even if it was soft-deleted, and there is no active draft version.
"""
try:
get_component_from_usage_key(usage_key)
except ObjectDoesNotExist:
return False
return True
def get_or_create_olx_media_type(block_type: str) -> MediaType:
"""
Get or create a MediaType for the block type.
Learning Core stores all Content with a Media Type (a.k.a. MIME type). For
OLX, we use the "application/vnd.*" convention, per RFC 6838.
"""
return authoring_api.get_or_create_media_type(
f"application/vnd.openedx.xblock.v1.{block_type}+xml"
)
def _create_component_for_block(content_lib, usage_key, user_id=None):
"""
Create a Component for an XBlock type, and initialize it.
This will create a Component, along with its first ComponentVersion. The tag
in the OLX will have no attributes, e.g. `<problem />`. This first version
will be set as the current draft. This function does not publish the
Component.
TODO: We should probably shift this to openedx.core.djangoapps.xblock.api
(along with its caller) since it gives runtime storage specifics. The
Library-specific logic stays in this module, so "create a block for my lib"
should stay here, but "making a block means creating a component with
text data like X" goes in xblock.api.
"""
display_name = xblock_type_display_name(usage_key.block_type)
now = datetime.now(tz=timezone.utc)
xml_text = f'<{usage_key.block_type} />'
learning_package = content_lib.learning_package
with transaction.atomic():
component_type = authoring_api.get_or_create_component_type(
"xblock.v1", usage_key.block_type
)
component, component_version = authoring_api.create_component_and_version(
learning_package.id,
component_type=component_type,
local_key=usage_key.block_id,
title=display_name,
created=now,
created_by=user_id,
)
content = authoring_api.get_or_create_text_content(
learning_package.id,
get_or_create_olx_media_type(usage_key.block_type).id,
text=xml_text,
created=now,
)
authoring_api.create_component_version_content(
component_version.pk,
content.id,
key="block.xml",
learner_downloadable=False
)
def delete_library_block(usage_key, remove_from_parent=True):
"""
Delete the specified block from this library (soft delete).
"""
component = get_component_from_usage_key(usage_key)
authoring_api.soft_delete_draft(component.pk)
LIBRARY_BLOCK_DELETED.send_event(
library_block=LibraryBlockData(
library_key=usage_key.context_key,
usage_key=usage_key
)
)
def get_library_block_static_asset_files(usage_key) -> list[LibraryXBlockStaticFile]:
"""
Given an XBlock in a content library, list all the static asset files
associated with that XBlock.
Returns a list of LibraryXBlockStaticFile objects, sorted by path.
TODO: This is not yet implemented for Learning Core backed libraries.
TODO: Should this be in the general XBlock API rather than the libraries API?
"""
return []
def add_library_block_static_asset_file(usage_key, file_name, file_content) -> LibraryXBlockStaticFile:
"""
Upload a static asset file into the library, to be associated with the
specified XBlock. Will silently overwrite an existing file of the same name.
file_name should be a name like "doc.pdf". It may optionally contain slashes
like 'en/doc.pdf'
file_content should be a binary string.
Returns a LibraryXBlockStaticFile object.
Sends a LIBRARY_BLOCK_UPDATED event.
Example:
video_block = UsageKey.from_string("lb:VideoTeam:python-intro:video:1")
add_library_block_static_asset_file(video_block, "subtitles-en.srt", subtitles.encode('utf-8'))
"""
raise NotImplementedError("Static assets not yet implemented for Learning Core")
def delete_library_block_static_asset_file(usage_key, file_name):
"""
Delete a static asset file from the library.
Sends a LIBRARY_BLOCK_UPDATED event.
Example:
video_block = UsageKey.from_string("lb:VideoTeam:python-intro:video:1")
delete_library_block_static_asset_file(video_block, "subtitles-en.srt")
"""
raise NotImplementedError("Static assets not yet implemented for Learning Core")
def get_allowed_block_types(library_key): # pylint: disable=unused-argument
"""
Get a list of XBlock types that can be added to the specified content
library.
"""
# This import breaks in the LMS so keep it here. The LMS doesn't generally
# use content libraries APIs directly but some tests may want to use them to
# create libraries and then test library learning or course-library integration.
from cms.djangoapps.contentstore import helpers as studio_helpers
# TODO: return support status and template options
# See cms/djangoapps/contentstore/views/component.py
block_types = sorted(name for name, class_ in XBlock.load_classes())
lib = get_library(library_key)
if lib.type != COMPLEX:
# Problem and Video libraries only permit XBlocks of the same name.
block_types = (name for name in block_types if name == lib.type)
info = []
for block_type in block_types:
# TODO: unify the contentstore helper with the xblock.api version of
# xblock_type_display_name
display_name = studio_helpers.xblock_type_display_name(block_type, None)
# For now as a crude heuristic, we exclude blocks that don't have a display_name
if display_name:
info.append(LibraryXBlockType(block_type=block_type, display_name=display_name))
return info
def publish_changes(library_key, user_id=None):
"""
Publish all pending changes to the specified library.
"""
learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package
authoring_api.publish_all_drafts(learning_package.id, published_by=user_id)
CONTENT_LIBRARY_UPDATED.send_event(
content_library=ContentLibraryData(
library_key=library_key,
update_blocks=True
)
)
def revert_changes(library_key):
"""
Revert all pending changes to the specified library, restoring it to the
last published version.
"""
learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package
authoring_api.reset_drafts_to_published(learning_package.id)
CONTENT_LIBRARY_UPDATED.send_event(
content_library=ContentLibraryData(
library_key=library_key,
update_blocks=True
)
)