Skip to content

Commit 25142ea

Browse files
committed
fixup! feat: add authz permission for the course authoring list
1 parent a06e758 commit 25142ea

1 file changed

Lines changed: 163 additions & 16 deletions

File tree

cms/djangoapps/contentstore/views/course.py

Lines changed: 163 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,16 @@
3232
from opaque_keys import InvalidKeyError
3333
from opaque_keys.edx.keys import CourseKey
3434
from opaque_keys.edx.locator import BlockUsageLocator
35+
from openedx.core.djangoapps.authz.decorators import LegacyAuthoringPermission, user_has_course_permission
3536
from organizations.api import add_organization_course, ensure_organization
3637
from organizations.exceptions import InvalidOrganizationException
3738
from rest_framework.exceptions import ValidationError
3839
from rest_framework.decorators import api_view
40+
from openedx_authz.api import get_scopes_for_user_and_permission
41+
from openedx_authz.constants.permissions import COURSES_VIEW_COURSE
42+
from openedx_authz.api.data import CourseOverviewData
3943
from openedx.core.lib.api.view_utils import view_auth_classes
44+
from openedx.core import toggles as core_toggles
4045

4146
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info
4247
from cms.djangoapps.course_creators.views import add_user_with_status_unrequested, get_course_creator_status
@@ -385,7 +390,9 @@ def get_in_process_course_actions(request):
385390
exclude_args={'state': CourseRerunUIStateManager.State.SUCCEEDED},
386391
should_display=True,
387392
)
388-
if has_studio_read_access(request.user, course.course_key)
393+
if user_has_course_permission(
394+
request.user, COURSES_VIEW_COURSE.identifier, course.course_key, LegacyAuthoringPermission.READ
395+
)
389396
]
390397

391398

@@ -750,26 +757,166 @@ def course_index(request, course_key):
750757
return redirect(get_course_outline_url(course_key, block_to_show))
751758

752759

760+
def _has_authz_access(course_key, is_staff_user, authz_scopes):
761+
"""Returns True if the user has access to the course based on authz scopes, False otherwise."""
762+
if is_staff_user:
763+
return True
764+
765+
for access in authz_scopes:
766+
if access.course_id == course_key:
767+
return True
768+
769+
return False
770+
771+
772+
def _has_legacy_access(course_key, is_staff_user, legacy_accesses):
773+
"""Returns True if the user has access to the course based on legacy accesses, False otherwise."""
774+
if is_staff_user:
775+
return True
776+
777+
for access in legacy_accesses:
778+
if access.course_id and access.course_id == course_key:
779+
return True
780+
elif access.org and access.course_id is None and access.org == course_key.org:
781+
return True
782+
783+
return False
784+
785+
786+
def _apply_query_filters(request, courses):
787+
"""Applies all query filters to the given courses queryset.
788+
This includes filtering by active/archived status, search query, ordering
789+
and any special filters (e.g. CCX courses, template courses). The filters are applied in the following order:
790+
1. Special filters (e.g. CCX courses, template courses)
791+
2. Active/archived status
792+
3. Search query
793+
4. Ordering
794+
"""
795+
796+
def filter_course(course):
797+
"""
798+
Special filters
799+
"""
800+
# CCXs cannot be edited in Studio (aka cms) and should not be shown in this dashboard.
801+
include_course = not isinstance(course.id, CCXLocator)
802+
803+
# TODO remove this condition when templates purged from db
804+
include_course = include_course and course.location.course != 'templates'
805+
806+
return include_course
807+
808+
filtered_courses = filter(filter_course, courses)
809+
810+
search_query, order, active_only, archived_only = get_query_params_if_present(request)
811+
812+
return get_filtered_and_ordered_courses(
813+
filtered_courses,
814+
active_only,
815+
archived_only,
816+
search_query,
817+
order,
818+
)
819+
820+
821+
def _get_candidate_course_keys(request):
822+
"""Returns a list of candidate course keys that the user may have access to,
823+
based on both authz scopes and legacy group-based access.
824+
Also returns the authz scopes and legacy accesses for further processing."""
825+
user = request.user
826+
827+
# Authz start --------------------------------------
828+
# Recolecting all course keys from authz scopes
829+
authz_scopes = get_scopes_for_user_and_permission(
830+
user.username,
831+
COURSES_VIEW_COURSE.identifier
832+
)
833+
834+
authz_keys = {
835+
access.course_id
836+
for access in authz_scopes
837+
if access.course_id is not None and isinstance(access, CourseOverviewData)
838+
}
839+
# Authz end -----------------------------------------
840+
841+
# Legacy start --------------------------------------
842+
# Recolecting all course keys from django groups
843+
instructor_courses = UserBasedRole(user, CourseInstructorRole.ROLE).courses_with_role()
844+
845+
with strict_role_checking():
846+
staff_courses = UserBasedRole(user, CourseStaffRole.ROLE).courses_with_role()
847+
848+
group_keys = set()
849+
org_accesses = set()
850+
legacy_accesses = instructor_courses | staff_courses
851+
852+
for access in legacy_accesses:
853+
if access.course_id is not None:
854+
group_keys.add(access.course_id)
855+
elif access.org:
856+
org_accesses.add(access.org)
857+
else:
858+
# No course_id or org is associated with this access.
859+
pass
860+
861+
if org_accesses:
862+
# Getting courses from user global orgs
863+
all_courses_give_an_org = CourseOverview.get_all_courses(orgs=list(org_accesses))
864+
org_course_keys = {overview.id for overview in all_courses_give_an_org}
865+
group_keys.update(org_course_keys)
866+
# Legacy end ----------------------------------------
867+
868+
return list(authz_keys | group_keys), authz_scopes, legacy_accesses
869+
870+
753871
@function_trace('get_courses_accessible_to_user')
754872
def get_courses_accessible_to_user(request):
755873
"""
756-
Try to get all courses by first reversing django groups and fallback to old method if it fails
757-
Note: overhead of pymongo reads will increase if getting courses from django groups fails
758-
759-
Arguments:
760-
request: the request object
874+
Hybrid approach:
875+
- Single-pass decision per course (authz vs legacy)
876+
- Batch DB fetch for performance
761877
"""
762-
if GlobalStaff().has_user(request.user):
763-
# user has global access so no need to get courses from django groups
764-
courses, in_process_course_actions = _accessible_courses_summary_iter(request)
878+
user = request.user
879+
is_staff_user = GlobalStaff().has_user(user) or user.is_superuser
880+
authz_scopes = None
881+
legacy_accesses = None
882+
883+
# Step 1: Determine candidate keys
884+
if is_staff_user:
885+
# unavoidable full scan
886+
candidate_courses = CourseOverview.get_all_courses()
887+
candidate_keys = [c.id for c in candidate_courses]
765888
else:
766-
try:
767-
courses, in_process_course_actions = _accessible_courses_list_from_groups(request)
768-
except AccessListFallback:
769-
# user have some old groups or there was some error getting courses from django groups
770-
# so fallback to iterating through all courses
771-
courses, in_process_course_actions = _accessible_courses_summary_iter(request)
772-
return courses, in_process_course_actions
889+
candidate_keys, authz_scopes, legacy_accesses = _get_candidate_course_keys(request)
890+
891+
if not candidate_keys:
892+
return [], []
893+
894+
# Step 2: Single-pass decision → collect valid keys
895+
valid_course_keys = set()
896+
897+
for course_key in candidate_keys:
898+
if core_toggles.enable_authz_course_authoring(course_key):
899+
if _has_authz_access(course_key, is_staff_user, authz_scopes):
900+
valid_course_keys.add(course_key)
901+
else:
902+
if _has_legacy_access(course_key, is_staff_user, legacy_accesses):
903+
valid_course_keys.add(course_key)
904+
905+
if not valid_course_keys:
906+
return [], []
907+
908+
# Step 3: Batch fetch (key optimization)
909+
courses = CourseOverview.get_all_courses(
910+
filter_={'id__in': list(valid_course_keys)}
911+
)
912+
913+
# Step 4: Apply filters once
914+
courses = _apply_query_filters(request, courses)
915+
916+
# Step 5: Compute actions once
917+
in_process_actions = get_in_process_course_actions(request)
918+
919+
return list(courses), in_process_actions
773920

774921

775922
def _process_courses_list(courses_iter, in_process_course_actions, split_archived=False):

0 commit comments

Comments
 (0)