Skip to content

Commit f342499

Browse files
committed
squash!: Support glob scopes
1 parent 28c1675 commit f342499

3 files changed

Lines changed: 130 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
@@ -321,7 +321,7 @@ def get_external_key(self, obj: dict) -> str:
321321

322322
def get_display_name(self, obj: dict) -> str:
323323
"""Get the display name for the given scope."""
324-
return str(obj.get("display_name") or "")
324+
return str(obj.get("display_name_col") or "")
325325

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

openedx_authz/rest_api/v1/views.py

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

2525
from openedx_authz import api
26-
from openedx_authz.api.data import ContentLibraryData, CourseOverviewData
26+
from openedx_authz.api.data import (
27+
ContentLibraryData,
28+
CourseOverviewData,
29+
OrgContentLibraryGlobData,
30+
OrgCourseOverviewGlobData,
31+
)
2732
from openedx_authz.api.users import get_scopes_for_user_and_permission
2833
from openedx_authz.api.utils import get_user_map
2934
from openedx_authz.constants import permissions
@@ -606,6 +611,7 @@ class AdminConsoleScopesAPIView(generics.ListAPIView):
606611
- search (Optional): Search term to filter scopes by display name
607612
- page (Optional): Page number for pagination
608613
- page_size (Optional): Number of items per page
614+
- type (Optional): Filter scopes by type. Supported values are `course` and `library`.
609615
- management_permission_only (Optional): Filter scopes either by only the ones to which the user has "manage team"
610616
permissions (if true), or just "view team" permissions.
611617
@@ -659,43 +665,57 @@ def get_serializer_context(self):
659665
context["org_map"] = Organization.objects.filter(active=True).in_bulk(field_name="short_name")
660666
return context
661667

662-
def _get_courses_queryset(self, allowed_ids: set | None = None, search: str = "") -> QuerySet:
668+
def _get_courses_queryset(
669+
self, allowed_ids: set | None = None, allowed_orgs: set | None = None, search: str = ""
670+
) -> QuerySet:
663671
"""Return a CourseOverview queryset projected to the unified scope shape.
664672
665-
If allowed_ids is provided, filter to only those course keys.
673+
If allowed_ids and/or allowed_orgs are provided, filter to matching courses.
666674
If search is provided, filter by display_name.
667675
"""
668676
qs = CourseOverview.objects
669-
if allowed_ids is not None:
670-
qs = qs.filter(id__in=allowed_ids)
677+
if allowed_ids is not None or allowed_orgs is not None:
678+
org_filter = Q(org__in=allowed_orgs) if allowed_orgs else Q()
679+
id_filter = Q(id__in=allowed_ids) if allowed_ids else Q()
680+
qs = qs.filter(org_filter | id_filter)
671681
if search:
672682
qs = qs.filter(display_name__icontains=search)
673683
return qs.annotate(
674-
scope_id=Cast("id", output_field=CharField()),
675-
org_name=Cast("org", output_field=CharField()),
676-
scope_type=Value("course", output_field=CharField()),
677-
).values("scope_id", "display_name", "org_name", "scope_type")
678-
679-
def _get_libraries_queryset(self, allowed_pairs: set | None = None, search: str = "") -> QuerySet:
684+
scope_id=Cast("id", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
685+
display_name_col=Cast("display_name", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
686+
org_name=Cast("org", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
687+
scope_type=Value("course", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
688+
).values("scope_id", "display_name_col", "org_name", "scope_type")
689+
690+
def _get_libraries_queryset(
691+
self, allowed_pairs: set | None = None, allowed_orgs: set | None = None, search: str = ""
692+
) -> QuerySet:
680693
"""Return a ContentLibrary queryset projected to the unified scope shape.
681694
682-
If allowed_pairs is provided, filter to only those (org, slug) pairs.
695+
If allowed_pairs and/or allowed_orgs are provided, filter to matching libraries.
683696
If search is provided, filter by learning_package__title.
684697
"""
685698
qs = ContentLibrary.objects
686-
if allowed_pairs is not None:
687-
if not allowed_pairs:
699+
if allowed_pairs is not None or allowed_orgs is not None:
700+
org_filter = Q(org__short_name__in=allowed_orgs) if allowed_orgs else Q()
701+
pair_filter = (
702+
reduce(operator.or_, (Q(org__short_name=org, slug=slug) for org, slug in allowed_pairs))
703+
if allowed_pairs
704+
else Q()
705+
)
706+
combined = org_filter | pair_filter
707+
if not combined:
688708
qs = qs.none()
689709
else:
690-
qs = qs.filter(reduce(operator.or_, (Q(org__short_name=org, slug=slug) for org, slug in allowed_pairs)))
710+
qs = qs.filter(combined)
691711
if search:
692712
qs = qs.filter(learning_package__title__icontains=search)
693713
return qs.annotate(
694-
scope_id=Cast("slug", output_field=CharField()),
695-
display_name=Cast("learning_package__title", output_field=CharField()),
696-
org_name=Cast("org__short_name", output_field=CharField()),
697-
scope_type=Value("library", output_field=CharField()),
698-
).values("scope_id", "display_name", "org_name", "scope_type")
714+
scope_id=Cast("slug", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
715+
display_name_col=Cast("learning_package__title", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
716+
org_name=Cast("org__short_name", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
717+
scope_type=Value("library", output_field=CharField(db_collation="utf8mb4_unicode_ci")),
718+
).values("scope_id", "display_name_col", "org_name", "scope_type")
699719

700720
def _build_queryset(self, courses_qs: QuerySet | None, libraries_qs: QuerySet | None) -> QuerySet:
701721
"""Union the provided querysets and sort by org. If only one is provided, return it sorted directly."""
@@ -730,24 +750,28 @@ def get_permission(scope_cls):
730750
# Resolve allowed scopes from Casbin in a single call per scope type.
731751
courses_qs = None
732752
if scope_type != "library":
753+
allowed_course_scopes = get_scopes_for_user_and_permission(
754+
user.username, get_permission(CourseOverviewData).identifier
755+
)
733756
allowed_course_ids = {
734-
s.external_key
735-
for s in get_scopes_for_user_and_permission(
736-
user.username, get_permission(CourseOverviewData).identifier
737-
)
757+
s.external_key for s in allowed_course_scopes if not isinstance(s, OrgCourseOverviewGlobData)
738758
}
739-
courses_qs = self._get_courses_queryset(allowed_course_ids, search=search)
759+
allowed_course_orgs = {s.org for s in allowed_course_scopes if isinstance(s, OrgCourseOverviewGlobData)}
760+
courses_qs = self._get_courses_queryset(allowed_course_ids, allowed_course_orgs, search=search)
740761

741762
libraries_qs = None
742763
if scope_type != "course":
764+
allowed_library_scopes = get_scopes_for_user_and_permission(
765+
user.username, get_permission(ContentLibraryData).identifier
766+
)
743767
allowed_library_pairs = {
744768
# Library external keys have the format lib:<org>:<slug>
745769
(s.external_key.split(":")[1], s.external_key.split(":")[2])
746-
for s in get_scopes_for_user_and_permission(
747-
user.username, get_permission(ContentLibraryData).identifier
748-
)
770+
for s in allowed_library_scopes
771+
if not isinstance(s, OrgContentLibraryGlobData)
749772
}
750-
libraries_qs = self._get_libraries_queryset(allowed_library_pairs, search=search)
773+
allowed_library_orgs = {s.org for s in allowed_library_scopes if isinstance(s, OrgContentLibraryGlobData)}
774+
libraries_qs = self._get_libraries_queryset(allowed_library_pairs, allowed_library_orgs, search=search)
751775

752776
# Union the requested querysets and sort by org at the DB level.
753777
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)