forked from openedx/openedx-platform
-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathcourse.py
More file actions
2125 lines (1820 loc) · 83.6 KB
/
course.py
File metadata and controls
2125 lines (1820 loc) · 83.6 KB
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
"""
Views related to operations on course objects
"""
import copy
import json
import logging
import random
import re
import string
from typing import Dict # noqa: UP035
import django.utils
from ccx_keys.locator import CCXLocator
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.core.exceptions import FieldError, ImproperlyConfigured, PermissionDenied
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db.models import QuerySet
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_GET, require_http_methods
from drf_spectacular.utils import OpenApiParameter, OpenApiRequest, OpenApiResponse, extend_schema
from edx_django_utils.monitoring import function_trace
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import BlockUsageLocator
from openedx_authz.api import get_scopes_for_user_and_permission
from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData, ScopeData
from openedx_authz.constants.permissions import (
COURSES_MANAGE_COURSE_UPDATES,
COURSES_MANAGE_GROUP_CONFIGURATIONS,
COURSES_MANAGE_PAGES_AND_RESOURCES,
COURSES_VIEW_COURSE,
COURSES_VIEW_COURSE_UPDATES,
COURSES_VIEW_PAGES_AND_RESOURCES,
)
from organizations.api import add_organization_course, ensure_organization
from organizations.exceptions import InvalidOrganizationException
from organizations.models import Organization
from rest_framework.decorators import api_view
from rest_framework.exceptions import ValidationError
from cms.djangoapps.contentstore.api.views.utils import get_bool_param
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info
from cms.djangoapps.course_creators.models import CourseCreator
from cms.djangoapps.course_creators.views import add_user_with_status_unrequested, get_course_creator_status
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
from cms.djangoapps.models.settings.encoder import CourseSettingsEncoder
from cms.djangoapps.modulestore_migrator.data import ModulestoreMigration
from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError
from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager
from common.djangoapps.edxmako.shortcuts import render_to_response
from common.djangoapps.student.auth import (
has_course_author_access,
has_studio_advanced_settings_access,
has_studio_read_access,
has_studio_write_access,
is_content_creator,
)
from common.djangoapps.student.roles import (
CourseInstructorRole,
CourseStaffRole,
GlobalStaff,
OrgStaffRole,
UserBasedRole,
strict_role_checking,
)
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json
from common.djangoapps.util.string_utils import _has_non_ascii_characters
from openedx.core import toggles as core_toggles
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
from openedx.core.djangoapps.models.course_details import CourseDetails
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangolib.js_utils import dump_js_escaped_json
from openedx.core.lib.api.view_utils import view_auth_classes
from openedx.core.lib.course_tabs import CourseTabPluginManager
from xmodule.course_block import CourseBlock, CourseFields # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.error_block import ErrorBlock # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore import EdxJSONEncoder # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import DuplicateCourseError # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.tabs import ( # lint-amnesty, pylint: disable=wrong-import-order
CourseTab,
CourseTabList,
InvalidTabsException,
)
from ..course_group_config import COHORT_SCHEME, RANDOM_SCHEME, GroupConfiguration, GroupConfigurationsValidationError
from ..course_info_model import delete_course_update, get_course_updates, update_course_updates
from ..courseware_index import CoursewareSearchIndexer, SearchIndexingError
from ..tasks import rerun_course as rerun_course_task
from ..toggles import (
default_enable_flexible_peer_openassessments,
use_new_advanced_settings_page,
use_new_grading_page,
use_new_group_configurations_page,
use_new_schedule_details_page,
)
from ..utils import (
add_instructor,
get_advanced_settings_url,
get_course_grading,
get_course_outline_url,
get_course_rerun_context,
get_course_settings,
get_grading_url,
get_group_configurations_context,
get_group_configurations_url,
get_lms_link_for_item,
get_proctored_exam_settings_url,
get_schedule_details_url,
get_studio_home_url,
get_textbooks_url,
get_updates_url,
initialize_permissions,
remove_all_instructors,
reverse_course_url,
reverse_library_url,
reverse_url,
reverse_usage_url,
update_course_details,
update_course_discussions_settings,
)
from .component import ADVANCED_COMPONENT_TYPES
log = logging.getLogger(__name__)
User = get_user_model()
__all__ = ['course_info_handler', 'course_handler', 'course_listing',
'course_info_update_handler', 'course_search_index_handler',
'course_rerun_handler',
'settings_handler',
'library_listing',
'grading_handler',
'advanced_settings_handler',
'course_notifications_handler',
'textbooks_list_handler', 'textbooks_detail_handler',
'group_configurations_list_handler', 'group_configurations_detail_handler',
'get_course_and_check_access', 'bulk_enable_disable_discussions']
class AccessListFallback(Exception):
"""
An exception that is raised whenever we need to `fall back` to fetching *all* courses
available to a user, rather than using a shorter method (i.e. fetching by group)
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def _get_course_block(course_key, depth=0):
"""
Function used to calculate and return the locator and course block
for the view functions in this file.
"""
course_block = modulestore().get_course(course_key, depth=depth)
return course_block
def get_course_and_check_access(course_key, user, depth=0):
"""
Function used to validate permission and return a course block
for the view functions in this file.
"""
if not has_studio_read_access(user, course_key):
raise PermissionDenied()
return _get_course_block(course_key, depth)
def get_course_and_check_manage_group_configurations_access(course_key, user, depth=0):
"""
Function used to validate permission and return a course block
for the view functions for group configurations in this file.
"""
if not user_has_course_permission(
user=user,
authz_permission=COURSES_MANAGE_GROUP_CONFIGURATIONS.identifier,
course_key=course_key,
legacy_permission=LegacyAuthoringPermission.READ
):
raise PermissionDenied()
return _get_course_block(course_key, depth)
def reindex_course_and_check_access(course_key, user):
"""
Internal method used to restart indexing on a course.
"""
if not has_course_author_access(user, course_key):
raise PermissionDenied()
return CoursewareSearchIndexer.do_course_reindex(modulestore(), course_key)
@login_required
def course_notifications_handler(request, course_key_string=None, action_state_id=None):
"""
Handle incoming requests for notifications in a RESTful way.
course_key_string and action_state_id must both be set; else a HttpBadResponseRequest is returned.
For each of these operations, the requesting user must have access to the course;
else a PermissionDenied error is returned.
GET
json: return json representing information about the notification (action, state, etc)
DELETE
json: return json repressing success or failure of dismissal/deletion of the notification
PUT
Raises a NotImplementedError.
POST
Raises a NotImplementedError.
"""
# ensure that we have a course and an action state
if not course_key_string or not action_state_id:
return HttpResponseBadRequest()
response_format = request.GET.get('format') or request.POST.get('format') or 'html'
course_key = CourseKey.from_string(course_key_string)
if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
if not has_studio_write_access(request.user, course_key):
raise PermissionDenied()
if request.method == 'GET':
return _course_notifications_json_get(action_state_id)
elif request.method == 'DELETE':
# we assume any delete requests dismiss actions from the UI
return _dismiss_notification(request, action_state_id)
elif request.method == 'PUT':
raise NotImplementedError()
elif request.method == 'POST':
raise NotImplementedError()
else:
return HttpResponseBadRequest()
else:
return HttpResponseNotFound()
def _course_notifications_json_get(course_action_state_id):
"""
Return the action and the action state for the given id
"""
try:
action_state = CourseRerunState.objects.find_first(id=course_action_state_id)
except CourseActionStateItemNotFoundError:
return HttpResponseBadRequest()
action_state_info = {
'action': action_state.action,
'state': action_state.state,
'should_display': action_state.should_display
}
return JsonResponse(action_state_info)
def _dismiss_notification(request, course_action_state_id):
"""
Update the display of the course notification
"""
try:
action_state = CourseRerunState.objects.find_first(id=course_action_state_id)
except CourseActionStateItemNotFoundError:
# Can't dismiss a notification that doesn't exist in the first place
return HttpResponseBadRequest()
if action_state.state == CourseRerunUIStateManager.State.FAILED:
# We remove all permissions for this course key at this time, since
# no further access is required to a course that failed to be created.
remove_all_instructors(action_state.course_key)
# The CourseRerunState is no longer needed by the UI; delete
action_state.delete()
return JsonResponse({'success': True})
@login_required
def course_handler(request, course_key_string=None):
"""
The restful handler for course specific requests.
It provides the course tree with the necessary information for identifying and labeling the parts. The root
will typically be a 'course' object but may not be especially as we support blocks.
GET
html: return course listing page if not given a course id
html: return html page overview for the given course if given a course id
json: return json representing the course branch's index entry as well as dag w/ all of the children
replaced w/ json docs where each doc has {'_id': , 'display_name': , 'children': }
POST
json: create a course, return resulting json
descriptor (same as in GET course/...). Leaving off /branch/draft would imply create the course w/ default
branches. Cannot change the structure contents ('_id', 'display_name', 'children') but can change the
index entry.
PUT
json: update this course (index entry not xblock) such as repointing head, changing display name, org,
course, run. Return same json as above.
DELETE
json: delete this branch from this course (leaving off /branch/draft would imply delete the course)
"""
try:
if course_key_string:
course_key = CourseKey.from_string(course_key_string)
if course_key.deprecated:
logging.error(f"User {request.user.id} tried to access Studio for Old Mongo course {course_key}.")
return HttpResponseNotFound()
response_format = request.GET.get('format') or request.POST.get('format') or 'html'
if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
if request.method == 'GET':
course_key = CourseKey.from_string(course_key_string)
with modulestore().bulk_operations(course_key):
course_block = get_course_and_check_access(course_key, request.user, depth=None)
return JsonResponse(_course_outline_json(request, course_block))
elif request.method == 'POST': # not sure if this is only post. If one will have ids, it goes after access
return _create_or_rerun_course(request)
elif not has_studio_write_access(request.user, CourseKey.from_string(course_key_string)):
raise PermissionDenied()
elif request.method == 'PUT':
raise NotImplementedError()
elif request.method == 'DELETE':
raise NotImplementedError()
else:
return HttpResponseBadRequest()
elif request.method == 'GET': # assume html
if course_key_string is None:
return redirect(reverse('home'))
else:
return course_index(request, CourseKey.from_string(course_key_string))
else:
return HttpResponseNotFound()
except InvalidKeyError:
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from # noqa: B904
@login_required
@ensure_csrf_cookie
@require_http_methods(["GET"])
def course_rerun_handler(request, course_key_string):
"""
The restful handler for course reruns.
GET
html: return html page with form to rerun a course for the given course id
"""
# Only global staff (PMs) are able to rerun courses during the soft launch
if not GlobalStaff().has_user(request.user):
raise PermissionDenied()
course_key = CourseKey.from_string(course_key_string)
with modulestore().bulk_operations(course_key):
course_block = get_course_and_check_access(course_key, request.user, depth=3)
if request.method == 'GET':
course_rerun_context = get_course_rerun_context(course_key, course_block, request.user)
return render_to_response('course-create-rerun.html', course_rerun_context)
@login_required
@ensure_csrf_cookie
@require_GET
def course_search_index_handler(request, course_key_string):
"""
The restful handler for course indexing.
GET
html: return status of indexing task
json: return status of indexing task
"""
# Only global staff (PMs) are able to index courses
if not GlobalStaff().has_user(request.user):
raise PermissionDenied()
course_key = CourseKey.from_string(course_key_string)
content_type = request.META.get('CONTENT_TYPE', None)
if content_type is None:
content_type = "application/json; charset=utf-8"
with modulestore().bulk_operations(course_key):
try:
reindex_course_and_check_access(course_key, request.user)
except SearchIndexingError as search_err:
return HttpResponse(dump_js_escaped_json({
"user_message": search_err.error_list
}), content_type=content_type, status=500)
return HttpResponse(dump_js_escaped_json({
"user_message": _("Course has been successfully reindexed.")
}), content_type=content_type, status=200)
def _course_outline_json(request, course_block):
"""
Returns a JSON representation of the course block and recursively all of its children.
"""
is_concise = request.GET.get('format') == 'concise'
include_children_predicate = lambda xblock: not xblock.category == 'vertical'
if is_concise:
include_children_predicate = lambda xblock: xblock.has_children
return create_xblock_info(
course_block,
include_child_info=True,
course_outline=False if is_concise else True, # lint-amnesty, pylint: disable=simplifiable-if-expression
include_children_predicate=include_children_predicate,
is_concise=is_concise,
user=request.user
)
def get_in_process_course_actions(request):
"""
Get all in-process course actions
"""
return [
course for course in
CourseRerunState.objects.find_all(
exclude_args={'state': CourseRerunUIStateManager.State.SUCCEEDED},
should_display=True,
)
if user_has_course_permission(
request.user, COURSES_VIEW_COURSE.identifier, course.course_key, LegacyAuthoringPermission.READ
)
]
def _accessible_courses_summary_iter(request):
"""
List all courses available to the logged in user by iterating through all the courses
Arguments:
request: the request object
"""
def course_filter(course_summary):
"""
Filter out unusable and inaccessible courses
"""
# TODO remove this condition when templates purged from db
if course_summary.location.course == 'templates':
return False
return has_studio_read_access(request.user, course_summary.id)
courses_summary = CourseOverview.get_all_courses()
search_query, order, active_only, archived_only = get_query_params_if_present(request)
courses_summary = get_filtered_and_ordered_courses(
courses_summary,
active_only,
archived_only,
search_query,
order,
)
courses_summary = filter(course_filter, courses_summary)
in_process_course_actions = get_in_process_course_actions(request)
return courses_summary, in_process_course_actions
def get_query_params_if_present(request):
"""
Returns the query params from request if present.
Arguments:
request: the request object
Returns:
search_query (str): any string used to filter Course Overviews based on visible fields.
order (str): any string used to order Course Overviews.
active_only (str): if not None, this value will limit the courses returned to active courses.
The default value is None.
archived_only (str): if not None, this value will limit the courses returned to archived courses.
The default value is None.
"""
allowed_query_params = ['search', 'order', 'active_only', 'archived_only']
if not any(param in request.GET for param in allowed_query_params):
return None, None, None, None
search_query = request.GET.get('search')
order = request.GET.get('order')
active_only = get_bool_param(request, 'active_only', None)
archived_only = get_bool_param(request, 'archived_only', None)
return search_query, order, active_only, archived_only
def get_filtered_and_ordered_courses(course_overviews, active_only, archived_only, search_query, order):
"""
Returns the filtered and ordered courses based on the query params.
Arguments:
courses_summary (Course Overview objects): course overview queryset to be filtered.
active_only (str): if not None, this value will limit the courses returned to active courses.
The default value is None.
archived_only (str): if not None, this value will limit the courses returned to archived courses.
The default value is None.
search_query (str): any string used to filter Course Overviews based on visible fields.
order (str): any string used to order Course Overviews.
Returns:
Course Overview objects: queryset filtered and ordered based on the query params.
"""
course_overviews = get_courses_by_status(active_only, archived_only, course_overviews)
course_overviews = get_courses_by_search_query(search_query, course_overviews)
course_overviews = get_courses_order_by(order, course_overviews)
return course_overviews
def _accessible_courses_iter(request):
"""
List all courses available to the logged in user by iterating through all the courses.
"""
def course_filter(course):
"""
Filter out unusable and inaccessible courses
"""
if isinstance(course, ErrorBlock):
return False
# Custom Courses for edX (CCX) is an edX feature for re-using course content.
# CCXs cannot be edited in Studio (aka cms) and should not be shown in this dashboard.
if isinstance(course.id, CCXLocator):
return False
# TODO remove this condition when templates purged from db
if course.location.course == 'templates':
return False
return has_studio_read_access(request.user, course.id)
courses = filter(course_filter, modulestore().get_courses())
in_process_course_actions = get_in_process_course_actions(request)
return courses, in_process_course_actions
def _accessible_courses_iter_for_tests(request):
"""
List all courses available to the logged in user by iterating through all the courses.
CourseSummary objects are used for listing purposes.
This method is only used by tests.
"""
def course_filter(course):
"""
Filter out unusable and inaccessible courses
"""
# Custom Courses for edX (CCX) is an edX feature for re-using course content.
# CCXs cannot be edited in Studio (aka cms) and should not be shown in this dashboard.
if isinstance(course.id, CCXLocator):
return False
# TODO remove this condition when templates purged from db
if course.location.course == 'templates':
return False
return has_studio_read_access(request.user, course.id)
courses = filter(course_filter, CourseOverview.get_all_courses())
in_process_course_actions = get_in_process_course_actions(request)
return courses, in_process_course_actions
def _accessible_courses_list_from_groups(request):
"""
List all courses available to the logged in user by reversing access group names
"""
def filter_ccx(course_access):
""" CCXs cannot be edited in Studio and should not be shown in this dashboard """
return not isinstance(course_access.course_id, CCXLocator)
instructor_courses = UserBasedRole(request.user, CourseInstructorRole.ROLE).courses_with_role()
with strict_role_checking():
staff_courses = UserBasedRole(request.user, CourseStaffRole.ROLE).courses_with_role()
all_courses = list(filter(filter_ccx, instructor_courses | staff_courses))
courses_list = []
course_keys = {}
user_global_orgs = set()
for course_access in all_courses:
if course_access.course_id is not None:
course_keys[course_access.course_id] = course_access.course_id
elif course_access.org:
user_global_orgs.add(course_access.org)
else:
raise AccessListFallback
if user_global_orgs:
# Getting courses from user global orgs
overviews = CourseOverview.get_all_courses(orgs=list(user_global_orgs))
overviews_course_keys = {overview.id: overview.id for overview in overviews}
course_keys.update(overviews_course_keys)
course_keys = list(course_keys.values())
if course_keys:
courses_list = CourseOverview.get_all_courses(filter_={'id__in': course_keys})
else:
# If no course keys are found for the current user, then return without filtering
# or ordering the courses list.
return courses_list, []
search_query, order, active_only, archived_only = get_query_params_if_present(request)
courses_list = get_filtered_and_ordered_courses(
courses_list,
active_only,
archived_only,
search_query,
order,
)
return courses_list, []
def get_courses_by_status(
active_only: bool,
archived_only: bool,
course_overviews: QuerySet[CourseOverview]
) -> QuerySet[CourseOverview]:
"""
Return course overviews based on a base queryset filtered by a status.
Args:
active_only (str): if not None, this value will limit the courses returned to active courses.
The default value is None.
archived_only (str): if not None, this value will limit the courses returned to archived courses.
The default value is None.
course_overviews (Course Overview objects): course overview queryset to be filtered.
"""
return CourseOverview.get_courses_by_status(active_only, archived_only, course_overviews)
def get_courses_by_search_query(
search_query: str | None,
course_overviews: QuerySet[CourseOverview]
) -> QuerySet[CourseOverview]:
"""Return course overviews based on a base queryset filtered by a search query.
Args:
search_query (str): any string used to filter Course Overviews based on visible fields.
course_overviews (Course Overview objects): course overview queryset to be filtered.
"""
if not search_query:
return course_overviews
return CourseOverview.get_courses_matching_query(search_query, course_overviews=course_overviews)
def get_courses_order_by(
order_query: str | None,
course_overviews: QuerySet[CourseOverview]
) -> QuerySet[CourseOverview]:
"""Return course overviews based on a base queryset ordered by a query.
Args:
order_query (str): any string used to order Course Overviews.
course_overviews (Course Overview objects): queryset to be ordered.
"""
if not order_query:
return course_overviews
try:
return course_overviews.order_by(order_query)
except FieldError as e:
log.exception(f"Error ordering courses by {order_query}: {e}")
return course_overviews
@function_trace('_accessible_libraries_iter')
def _accessible_libraries_iter(user, org=None):
"""
List all libraries available to the logged in user by iterating through all libraries.
org (string): if not None, this value will limit the libraries returned. An empty
string will result in no libraries, and otherwise only libraries with the
specified org will be returned. The default value is None.
"""
if org is not None:
libraries = [] if org == '' else modulestore().get_libraries(org=org)
else:
libraries = modulestore().get_library_summaries()
# No need to worry about ErrorBlocks - split's get_libraries() never returns them.
return (lib for lib in libraries if has_studio_read_access(user, lib.location.library_key))
@login_required
@ensure_csrf_cookie
def course_listing(request):
"""
List all courses and libraries available to the logged in user
"""
return redirect(get_studio_home_url())
@login_required
@ensure_csrf_cookie
def library_listing(request):
"""
List all Libraries available to the logged in user
"""
mfe_base_url = settings.COURSE_AUTHORING_MICROFRONTEND_URL
if mfe_base_url:
return redirect(f'{mfe_base_url}/libraries')
raise ImproperlyConfigured(
"The COURSE_AUTHORING_MICROFRONTEND_URL must be configured. "
"Please set it to the base url for your authoring MFE."
)
def format_library_for_view(library, request, migration: ModulestoreMigration | None):
"""
Return a dict of the data which the view requires for each library
"""
migration_info = {}
if migration:
migration_info = {
'migrated_to_key': migration.target_key,
'migrated_to_title': migration.target_title,
'migrated_to_collection_key': migration.target_collection_slug,
'migrated_to_collection_title': migration.target_collection_title,
}
return {
'display_name': library.display_name,
'library_key': str(library.location.library_key),
'url': reverse_library_url('library_handler', str(library.location.library_key)),
'org': library.display_org_with_default,
'number': library.display_number_with_default,
'can_edit': has_studio_write_access(request.user, library.location.library_key),
'is_migrated': migration is not None,
**migration_info,
}
def _get_rerun_link_for_item(course_key):
""" Returns the rerun link for the given course key. """
return reverse_course_url('course_rerun_handler', course_key)
def _deprecated_blocks_info(course_block, deprecated_block_types):
"""
Returns deprecation information about `deprecated_block_types`
Arguments:
course_block (CourseBlock): course object
deprecated_block_types (list): list of deprecated blocks types
Returns:
Dict with following keys:
deprecated_enabled_block_types (list): list containing all deprecated blocks types enabled on this course
blocks (list): List of `deprecated_enabled_block_types` instances and their parent's url
advance_settings_url (str): URL to advance settings page
"""
data = {
'deprecated_enabled_block_types': [
block_type for block_type in course_block.advanced_modules if block_type in deprecated_block_types
],
'blocks': [],
'advance_settings_url': reverse_course_url('advanced_settings_handler', course_block.id)
}
deprecated_blocks = modulestore().get_items(
course_block.id,
qualifiers={
'category': re.compile('^' + '$|^'.join(deprecated_block_types) + '$')
}
)
for block in deprecated_blocks:
data['blocks'].append([
reverse_usage_url('container_handler', block.parent),
block.display_name
])
return data
@login_required
@ensure_csrf_cookie
def course_index(request, course_key):
"""
Display an editable course overview.
org, course, name: Attributes of the Location for the item to edit
"""
block_to_show = request.GET.get("show")
return redirect(get_course_outline_url(course_key, block_to_show))
def _apply_course_query_filters(request, courses):
"""Applies all query filters to the given courses queryset.
This includes filtering by active/archived status, search query, ordering
and any special filters (e.g. CCX courses, template courses). The filters are applied in the following order:
1. Active/archived status
2. Search query
3. Ordering
4. Special filters (e.g. CCX courses, template courses)
The first 3 filters are applied using queryset methods, while the last filter is applied using a Python filter
function since it involves checking the course type (i.e. if it's a CCX course or a template course).
"""
def filter_course(course):
"""
Special filters
"""
# CCXs cannot be edited in Studio (aka cms) and should not be shown in this dashboard.
include_course = not isinstance(course.id, CCXLocator)
# TODO remove this condition when templates purged from db
include_course = include_course and course.location.course != 'templates'
return include_course
search_query, order, active_only, archived_only = get_query_params_if_present(request)
filtered_courses = get_filtered_and_ordered_courses(
courses,
active_only,
archived_only,
search_query,
order,
)
return filter(filter_course, filtered_courses)
def _get_course_keys_for_org_scope(org_keys: set[str]):
"""
Convert a set of organization keys into specific course keys.
"""
return CourseOverview.get_all_courses(orgs=org_keys).values_list('id', flat=True)
def _get_course_keys_from_scopes(authz_scopes: list[ScopeData]):
"""
Convert a set of Authz scopes into specific course keys.
"""
course_keys = set()
org_keys = set()
for access in authz_scopes:
if isinstance(access, CourseOverviewData) and access.course_key:
if core_toggles.enable_authz_course_authoring(access.course_key):
course_keys.add(access.course_key)
elif isinstance(access, OrgCourseOverviewGlobData) and access.org:
org_keys.add(access.org)
if org_keys:
course_keys.update(
key for key in _get_course_keys_for_org_scope(org_keys)
if core_toggles.enable_authz_course_authoring(key)
)
return course_keys
def _get_authz_accessible_courses_list(request):
"""
List all courses available to the logged in user by
evaluating Authz scopes for course access.
"""
user = request.user
authz_scopes = get_scopes_for_user_and_permission(
user.username,
COURSES_VIEW_COURSE.identifier
)
return _get_course_keys_from_scopes(authz_scopes)
def _get_legacy_accessible_courses_list(request):
"""
List all courses available to the logged in user by
evaluating legacy Django group roles and organization-level access.
"""
user = request.user
instructor_courses = UserBasedRole(user, CourseInstructorRole.ROLE).courses_with_role()
with strict_role_checking():
staff_courses = UserBasedRole(user, CourseStaffRole.ROLE).courses_with_role()
group_keys = set()
org_accesses = set()
legacy_accesses = instructor_courses | staff_courses
for access in legacy_accesses:
if access.course_id is not None:
course_key = access.course_id
if not isinstance(course_key, CourseKey):
course_key = CourseKey.from_string(str(course_key))
group_keys.add(course_key)
elif access.org:
org_accesses.add(access.org)
else:
# No course_id or org is associated with this access.
raise AccessListFallback
if org_accesses:
# Getting courses from user global orgs
org_course_keys = CourseOverview.get_all_courses(orgs=org_accesses).values_list("id", flat=True)
group_keys.update(org_course_keys)
return group_keys
def _get_candidate_course_keys(request):
"""
Resolve accessible course keys by merging Authz scope evaluation with
legacy permission checks.
Why merge Authz and legacy checks?
At the time of implementation, the system is in a transition phase where
both Authz scopes and legacy permission checks are required to determine
course access. Combining both approaches allows us to leverage the
efficiency of Authz scopes while still capturing access granted through
legacy mechanisms.
This produces a comprehensive and performant set of candidate course keys,
combining:
- Authz scopes:
Collects course keys from the user's scopes for the
`COURSES_VIEW_COURSE` permission.
- Legacy access:
Collects course keys based on Django group roles
(`CourseInstructorRole`, `CourseStaffRole`) and
organization-level access. If the user has organization-level access,
all courses within those organizations are included.
"""
# Collecting all course keys from authz scopes
authz_keys = _get_authz_accessible_courses_list(request)
# Collecting all course keys from django groups and org access
group_keys = _get_legacy_accessible_courses_list(request)
return authz_keys | group_keys
@function_trace('get_courses_accessible_to_user')
def get_courses_accessible_to_user(request):
"""
Return courses accessible to the user using a hybrid AuthZ + legacy approach.
Flow:
1. Determine candidate course keys:
- Staff: all courses (full scan).
- Non-staff: derived from AuthZ scopes and legacy access.
2. Single-pass access evaluation:
- Use AuthZ or legacy checks per course (based on feature flags).
- Collect only accessible course keys.
3. Batch fetch courses:
- Retrieve all valid courses in one query (ordered by creation date).
4. Apply request-based filters.
Returns:
tuple:
- list[CourseOverview]: Accessible courses.
- list: In-process course actions (staff only).
"""
user = request.user
is_staff_user = GlobalStaff().has_user(user) or user.is_superuser
in_process_actions = []
# Step 1: Determine candidate keys
if is_staff_user:
# Unavoidable full scan
# however, we only fetch the course keys here for the access check,
# and defer fetching the full course objects until after filtering by access
candidate_keys = CourseOverview.get_all_courses().values_list("id", flat=True)
# Compute actions once for staff users since they have access to all courses
in_process_actions = get_in_process_course_actions(request)
else:
# For non-staff users, we can get a more targeted list of candidate course keys
# by combining AuthZ scopes and legacy access.
# Why? Because non-staff users typically have access to a smaller subset of courses,
# so this can significantly reduce the number of courses we need to check for access
# in the next step.
try:
candidate_keys = _get_candidate_course_keys(request)
except AccessListFallback:
# This exception is raised when we cannot determine candidate course keys from legacy access.
# User have some old groups or there was some error getting courses from django groups
# so fallback to iterating through all courses
candidate_keys = CourseOverview.get_all_courses().values_list("id", flat=True)
in_process_actions = get_in_process_course_actions(request)
# Step 2: Single-pass decision → collect valid keys
valid_course_keys = set(candidate_keys)
if not valid_course_keys:
return [], in_process_actions
# Step 3: Batch fetch valid courses with a single query, ordered by creation date
courses = CourseOverview.get_all_courses(
filter_={'id__in': list(valid_course_keys)}
).order_by('created') # default ordering is by created date
# Step 4: Apply filters (e.g. search, active/archived status, ordering)
courses = _apply_course_query_filters(request, courses)
return courses, in_process_actions
def _process_courses_list(courses_iter, in_process_course_actions, split_archived=False):
"""
Iterates over the list of courses to be displayed to the user, and:
* Removes any in-process courses from the courses list. "In-process" refers to courses
that are in the process of being generated for re-run.
* If split_archived=True, removes any archived courses and returns them in a separate list.