Skip to content

Commit 5c8356b

Browse files
committed
squash!: Support glob scopes
1 parent 8038c9f commit 5c8356b

3 files changed

Lines changed: 132 additions & 45 deletions

File tree

openedx_authz/rest_api/v1/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ def get_external_key(self, obj: dict) -> str:
375375

376376
def get_display_name(self, obj: dict) -> str:
377377
"""Get the display name for the given scope."""
378-
return str(obj.get("display_name") or "")
378+
return str(obj.get("display_name_col") or "")
379379

380380
def get_org(self, obj: dict) -> dict | None:
381381
"""Get the org for the given scope."""

openedx_authz/rest_api/v1/views.py

Lines changed: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@
2323
from rest_framework.views import APIView
2424

2525
from openedx_authz import api
26-
from openedx_authz.api.data import ContentLibraryData, CourseOverviewData, RoleAssignmentData, SuperAdminAssignmentData
26+
from openedx_authz.api.data import (
27+
ContentLibraryData,
28+
CourseOverviewData,
29+
OrgContentLibraryGlobData,
30+
OrgCourseOverviewGlobData,
31+
RoleAssignmentData,
32+
SuperAdminAssignmentData,
33+
)
2734
from openedx_authz.api.users import (
2835
get_scopes_for_user_and_permission,
2936
get_superadmin_assignments,
@@ -616,6 +623,7 @@ class AdminConsoleScopesAPIView(generics.ListAPIView):
616623
- search (Optional): Search term to filter scopes by display name
617624
- page (Optional): Page number for pagination
618625
- page_size (Optional): Number of items per page
626+
- type (Optional): Filter scopes by type. Supported values are `course` and `library`.
619627
- management_permission_only (Optional): Filter scopes either by only the ones to which the user has "manage team"
620628
permissions (if true), or just "view team" permissions.
621629
@@ -669,43 +677,57 @@ def get_serializer_context(self):
669677
context["org_map"] = Organization.objects.filter(active=True).in_bulk(field_name="short_name")
670678
return context
671679

672-
def _get_courses_queryset(self, allowed_ids: set | None = None, search: str = "") -> QuerySet:
680+
def _get_courses_queryset(
681+
self, allowed_ids: set | None = None, allowed_orgs: set | None = None, search: str = ""
682+
) -> QuerySet:
673683
"""Return a CourseOverview queryset projected to the unified scope shape.
674684
675-
If allowed_ids is provided, filter to only those course keys.
685+
If allowed_ids and/or allowed_orgs are provided, filter to matching courses.
676686
If search is provided, filter by display_name.
677687
"""
678688
qs = CourseOverview.objects
679-
if allowed_ids is not None:
680-
qs = qs.filter(id__in=allowed_ids)
689+
if allowed_ids is not None or allowed_orgs is not None:
690+
org_filter = Q(org__in=allowed_orgs) if allowed_orgs else Q()
691+
id_filter = Q(id__in=allowed_ids) if allowed_ids else Q()
692+
qs = qs.filter(org_filter | id_filter)
681693
if search:
682694
qs = qs.filter(display_name__icontains=search)
683695
return qs.annotate(
684-
scope_id=Cast("id", output_field=CharField()),
685-
org_name=Cast("org", output_field=CharField()),
686-
scope_type=Value("course", output_field=CharField()),
687-
).values("scope_id", "display_name", "org_name", "scope_type")
688-
689-
def _get_libraries_queryset(self, allowed_pairs: set | None = None, search: str = "") -> QuerySet:
696+
scope_id=Cast("id", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
697+
display_name_col=Cast("display_name", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
698+
org_name=Cast("org", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
699+
scope_type=Value("course", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
700+
).values("scope_id", "display_name_col", "org_name", "scope_type")
701+
702+
def _get_libraries_queryset(
703+
self, allowed_pairs: set | None = None, allowed_orgs: set | None = None, search: str = ""
704+
) -> QuerySet:
690705
"""Return a ContentLibrary queryset projected to the unified scope shape.
691706
692-
If allowed_pairs is provided, filter to only those (org, slug) pairs.
707+
If allowed_pairs and/or allowed_orgs are provided, filter to matching libraries.
693708
If search is provided, filter by learning_package__title.
694709
"""
695710
qs = ContentLibrary.objects
696-
if allowed_pairs is not None:
697-
if not allowed_pairs:
711+
if allowed_pairs is not None or allowed_orgs is not None:
712+
org_filter = Q(org__short_name__in=allowed_orgs) if allowed_orgs else Q()
713+
pair_filter = (
714+
reduce(operator.or_, (Q(org__short_name=org, slug=slug) for org, slug in allowed_pairs))
715+
if allowed_pairs
716+
else Q()
717+
)
718+
combined = org_filter | pair_filter
719+
if not combined:
698720
qs = qs.none()
699721
else:
700-
qs = qs.filter(reduce(operator.or_, (Q(org__short_name=org, slug=slug) for org, slug in allowed_pairs)))
722+
qs = qs.filter(combined)
701723
if search:
702724
qs = qs.filter(learning_package__title__icontains=search)
703725
return qs.annotate(
704-
scope_id=Cast("slug", output_field=CharField()),
705-
display_name=Cast("learning_package__title", output_field=CharField()),
706-
org_name=Cast("org__short_name", output_field=CharField()),
707-
scope_type=Value("library", output_field=CharField()),
708-
).values("scope_id", "display_name", "org_name", "scope_type")
726+
scope_id=Cast("slug", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
727+
display_name_col=Cast("learning_package__title", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
728+
org_name=Cast("org__short_name", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
729+
scope_type=Value("library", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
730+
).values("scope_id", "display_name_col", "org_name", "scope_type")
709731

710732
def _build_queryset(self, courses_qs: QuerySet | None, libraries_qs: QuerySet | None) -> QuerySet:
711733
"""Union the provided querysets and sort by org. If only one is provided, return it sorted directly."""
@@ -740,24 +762,28 @@ def get_permission(scope_cls):
740762
# Resolve allowed scopes from Casbin in a single call per scope type.
741763
courses_qs = None
742764
if scope_type != "library":
765+
allowed_course_scopes = get_scopes_for_user_and_permission(
766+
user.username, get_permission(CourseOverviewData).identifier
767+
)
743768
allowed_course_ids = {
744-
s.external_key
745-
for s in get_scopes_for_user_and_permission(
746-
user.username, get_permission(CourseOverviewData).identifier
747-
)
769+
s.external_key for s in allowed_course_scopes if not isinstance(s, OrgCourseOverviewGlobData)
748770
}
749-
courses_qs = self._get_courses_queryset(allowed_course_ids, search=search)
771+
allowed_course_orgs = {s.org for s in allowed_course_scopes if isinstance(s, OrgCourseOverviewGlobData)}
772+
courses_qs = self._get_courses_queryset(allowed_course_ids, allowed_course_orgs, search=search)
750773

751774
libraries_qs = None
752775
if scope_type != "course":
776+
allowed_library_scopes = get_scopes_for_user_and_permission(
777+
user.username, get_permission(ContentLibraryData).identifier
778+
)
753779
allowed_library_pairs = {
754780
# Library external keys have the format lib:<org>:<slug>
755781
(s.external_key.split(":")[1], s.external_key.split(":")[2])
756-
for s in get_scopes_for_user_and_permission(
757-
user.username, get_permission(ContentLibraryData).identifier
758-
)
782+
for s in allowed_library_scopes
783+
if not isinstance(s, OrgContentLibraryGlobData)
759784
}
760-
libraries_qs = self._get_libraries_queryset(allowed_library_pairs, search=search)
785+
allowed_library_orgs = {s.org for s in allowed_library_scopes if isinstance(s, OrgContentLibraryGlobData)}
786+
libraries_qs = self._get_libraries_queryset(allowed_library_pairs, allowed_library_orgs, search=search)
761787

762788
# Union the requested querysets and sort by org at the DB level.
763789
return self._build_queryset(courses_qs, libraries_qs)

openedx_authz/tests/rest_api/test_views.py

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
from rest_framework.test import APIClient
2121

2222
from openedx_authz import api
23+
from openedx_authz.api.data import (
24+
OrgContentLibraryGlobData,
25+
OrgCourseOverviewGlobData,
26+
)
2327
from openedx_authz.api.users import assign_role_to_user_in_scope
2428
from openedx_authz.constants import permissions, roles
2529
from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model
@@ -937,10 +941,20 @@ def setUp(self):
937941

938942
# Default combined result used by most tests.
939943
self.fake_scopes = [
940-
{"scope_id": self.COURSE_ORG1, "display_name": "Course Org1", "org_name": "Org1", "scope_type": "course"},
941-
{"scope_id": "LIB1", "display_name": "Library LIB1", "org_name": "Org1", "scope_type": "library"},
942-
{"scope_id": self.COURSE_ORG2, "display_name": "Course Org2", "org_name": "Org2", "scope_type": "course"},
943-
{"scope_id": "LIB2", "display_name": "Library LIB2", "org_name": "Org2", "scope_type": "library"},
944+
{
945+
"scope_id": self.COURSE_ORG1,
946+
"display_name_col": "Course Org1",
947+
"org_name": "Org1",
948+
"scope_type": "course",
949+
},
950+
{"scope_id": "LIB1", "display_name_col": "Library LIB1", "org_name": "Org1", "scope_type": "library"},
951+
{
952+
"scope_id": self.COURSE_ORG2,
953+
"display_name_col": "Course Org2",
954+
"org_name": "Org2",
955+
"scope_type": "course",
956+
},
957+
{"scope_id": "LIB2", "display_name_col": "Library LIB2", "org_name": "Org2", "scope_type": "library"},
944958
]
945959

946960
# Patch _build_queryset so tests don't need real DB querysets.
@@ -955,21 +969,26 @@ def setUp(self):
955969
# The stub ContentLibrary uses 'title' directly instead of 'learning_package__title'.
956970
# Patch _get_libraries_queryset to use the stub-compatible field, aliased as 'display_name'
957971
# to match the column name the union and serializer expect.
958-
def stub_get_libraries_queryset(_, allowed_pairs=None, search=""): # pylint: disable=unused-argument
972+
def stub_get_libraries_queryset(_, allowed_pairs=None, allowed_orgs=None, search=""): # pylint: disable=unused-argument
959973
qs = ContentLibrary.objects
960-
if allowed_pairs is not None:
961-
if not allowed_pairs:
974+
if allowed_pairs is not None or allowed_orgs is not None:
975+
org_filter = Q(org__short_name__in=allowed_orgs) if allowed_orgs else Q()
976+
pair_filter = (
977+
reduce(operator.or_, (Q(org__short_name=org, slug=slug) for org, slug in allowed_pairs))
978+
if allowed_pairs
979+
else Q()
980+
)
981+
combined = org_filter | pair_filter
982+
if not combined:
962983
qs = qs.none()
963984
else:
964-
qs = qs.filter(
965-
reduce(operator.or_, (Q(org__short_name=org, slug=slug) for org, slug in allowed_pairs))
966-
)
985+
qs = qs.filter(combined)
967986
return qs.annotate(
968987
scope_id=Cast("slug", output_field=CharField()),
969-
display_name=Cast("title", output_field=CharField()),
988+
display_name_col=Cast("title", output_field=CharField()),
970989
org_name=Cast("org__short_name", output_field=CharField()),
971990
scope_type=Value("library", output_field=CharField()),
972-
).values("scope_id", "display_name", "org_name", "scope_type")
991+
).values("scope_id", "display_name_col", "org_name", "scope_type")
973992

974993
self.libraries_qs_patcher = patch.object(
975994
views.AdminConsoleScopesAPIView,
@@ -1103,9 +1122,9 @@ def test_search_filters_by_display_name(self):
11031122
def test_pagination(self, query_params: dict, expected_page_count: int, has_next: bool):
11041123
"""Results are paginated correctly."""
11051124
mixed = [
1106-
{"scope_id": self.COURSE_ORG1, "display_name": "Course 1", "org_name": "Org1", "scope_type": "course"},
1107-
{"scope_id": "LIB1", "display_name": "Library 1", "org_name": "Org1", "scope_type": "library"},
1108-
{"scope_id": self.COURSE_ORG2, "display_name": "Course 2", "org_name": "Org2", "scope_type": "course"},
1125+
{"scope_id": self.COURSE_ORG1, "display_name_col": "Course 1", "org_name": "Org1", "scope_type": "course"},
1126+
{"scope_id": "LIB1", "display_name_col": "Library 1", "org_name": "Org1", "scope_type": "library"},
1127+
{"scope_id": self.COURSE_ORG2, "display_name_col": "Course 2", "org_name": "Org2", "scope_type": "course"},
11091128
]
11101129
self.build_qs_patcher.stop()
11111130
with patch.object(views.AdminConsoleScopesAPIView, "_build_queryset", return_value=mixed):
@@ -1266,6 +1285,48 @@ def test_empty_allowed_library_pairs_returns_no_libraries(self):
12661285
self.assertEqual(response.status_code, status.HTTP_200_OK)
12671286
self.assertEqual(response.data["count"], 0)
12681287

1288+
def test_org_glob_scope_returns_all_org_libraries(self):
1289+
"""A user with an org-level glob permission (lib:ORG:*) sees all libraries in that org."""
1290+
user = User.objects.get(username="regular_1")
1291+
self.client.force_authenticate(user=user)
1292+
self.build_qs_patcher.stop()
1293+
1294+
# Simulate get_scopes_for_user_and_permission returning an org-level glob.
1295+
glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*")
1296+
with patch(
1297+
"openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission",
1298+
return_value=[glob_scope],
1299+
):
1300+
response = self.client.get(self.url, {"type": "library"})
1301+
1302+
self.build_qs_patcher.start()
1303+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1304+
external_keys = [item["external_key"] for item in response.data["results"]]
1305+
self.assertIn(self.LIBRARY_ORG1, external_keys)
1306+
self.assertNotIn(self.LIBRARY_ORG2, external_keys)
1307+
1308+
def test_org_glob_scope_returns_all_org_courses(self):
1309+
"""A user with an org-level glob permission (course-v1:ORG+*) sees all courses in that org."""
1310+
user = User.objects.get(username="regular_9")
1311+
self.client.force_authenticate(user=user)
1312+
self.build_qs_patcher.stop()
1313+
self.libraries_qs_patcher.stop()
1314+
1315+
# Simulate get_scopes_for_user_and_permission returning an org-level glob.
1316+
glob_scope = OrgCourseOverviewGlobData(external_key="course-v1:Org1+*")
1317+
with patch(
1318+
"openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission",
1319+
return_value=[glob_scope],
1320+
):
1321+
response = self.client.get(self.url, {"type": "course"})
1322+
1323+
self.libraries_qs_patcher.start()
1324+
self.build_qs_patcher.start()
1325+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1326+
external_keys = [item["external_key"] for item in response.data["results"]]
1327+
self.assertIn(self.COURSE_ORG1, external_keys)
1328+
self.assertNotIn(self.COURSE_ORG2, external_keys)
1329+
12691330
def test_manage_permission_only_uses_manage_permission(self):
12701331
"""management_permission_only=true calls get_admin_manage_permission, not get_admin_view_permission."""
12711332
user = User.objects.get(username="regular_1")

0 commit comments

Comments
 (0)