Skip to content

Commit 8a1faa1

Browse files
committed
feat: Support filtering by multiple orgs in scopes endpoint
1 parent d5bb0ac commit 8a1faa1

5 files changed

Lines changed: 203 additions & 19 deletions

File tree

CHANGELOG.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
1.14.0 - 2026-04-22
18+
*******************
19+
20+
Added
21+
=====
22+
23+
* Add optional ``orgs`` query param to the ``PUT /api/authz/v1/scopes/`` endpoint, that supports filtering results by multiple orgs.
24+
1725
1.13.0 - 2026-04-22
1826
*******************
1927

openedx_authz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import os
66

7-
__version__ = "1.13.0"
7+
__version__ = "1.14.0"
88

99
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

openedx_authz/rest_api/v1/serializers.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,15 +160,11 @@ def validate(self, attrs) -> dict:
160160
role_value = validated_data["role"]
161161

162162
if scope and scopes is not None:
163-
raise serializers.ValidationError(
164-
"Provide either 'scope' or 'scopes', not both."
165-
)
163+
raise serializers.ValidationError("Provide either 'scope' or 'scopes', not both.")
166164

167165
scopes_list = scopes if scopes is not None else ([scope] if scope else None)
168166
if not scopes_list:
169-
raise serializers.ValidationError(
170-
"Either 'scope' or 'scopes' must be provided."
171-
)
167+
raise serializers.ValidationError("Either 'scope' or 'scopes' must be provided.")
172168

173169
for scope_value in scopes_list:
174170
self._validate_scope_and_role(scope_value, role_value)
@@ -285,6 +281,7 @@ class ListScopesQuerySerializer(OrgMixin): # pylint: disable=abstract-method
285281
choices=[(e.value, e.name) for e in ScopesTypeField], required=False, default=None, allow_null=True
286282
)
287283
search = serializers.CharField(required=False, default="", allow_blank=True)
284+
orgs = CaseSensitiveCommaSeparatedListField(required=False, default=[])
288285

289286

290287
class ListTeamMembersSerializer(OrderMixin): # pylint: disable=abstract-method

openedx_authz/rest_api/v1/views.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,9 @@ def get_queryset(self) -> QuerySet:
595595
parameters=[
596596
apidocs.query_parameter("search", str, description="Filter scopes by display name"),
597597
apidocs.query_parameter("org", str, description="Filter scopes by org"),
598+
apidocs.query_parameter(
599+
"orgs", str, description="Filter scopes by multiple orgs (comma separated list of orgs)"
600+
),
598601
apidocs.query_parameter("page", int, description="Page number for pagination"),
599602
apidocs.query_parameter("page_size", int, description="Number of items per page"),
600603
apidocs.query_parameter(
@@ -631,6 +634,7 @@ class ScopesAPIView(generics.ListAPIView):
631634
632635
- search (Optional): Search term to filter scopes by display name
633636
- org (Optional): Filter scopes by org
637+
- orgs (Optional): Filter scopes by multiple orgs (comma separated list of orgs)
634638
- page (Optional): Page number for pagination
635639
- page_size (Optional): Number of items per page
636640
- scope_type (Optional): Filter scopes by type. Supported values are `course` and `library`.
@@ -700,7 +704,7 @@ def _get_courses_queryset(
700704
allowed_ids: set | None = None,
701705
allowed_orgs: set | None = None,
702706
search: str = "",
703-
org: str = "",
707+
orgs: set[str] | None = None,
704708
) -> QuerySet:
705709
"""Return a CourseOverview queryset projected to the unified scope shape.
706710
@@ -717,8 +721,8 @@ def _get_courses_queryset(
717721
qs = qs.none()
718722
else:
719723
qs = qs.filter(combined_filter)
720-
if org:
721-
qs = qs.filter(org=org)
724+
if orgs:
725+
qs = qs.filter(org__in=orgs)
722726
if search:
723727
qs = qs.filter(display_name__icontains=search)
724728
return qs.annotate(
@@ -738,7 +742,7 @@ def _get_libraries_queryset(
738742
allowed_pairs: set | None = None,
739743
allowed_orgs: set | None = None,
740744
search: str = "",
741-
org: str = "",
745+
orgs: set[str] | None = None,
742746
) -> QuerySet:
743747
"""Return a ContentLibrary queryset projected to the unified scope shape.
744748
@@ -759,8 +763,8 @@ def _get_libraries_queryset(
759763
qs = qs.none()
760764
else:
761765
qs = qs.filter(combined)
762-
if org:
763-
qs = qs.filter(org__short_name=org)
766+
if orgs:
767+
qs = qs.filter(org__short_name__in=orgs)
764768
if search:
765769
qs = qs.filter(learning_package__title__icontains=search)
766770
return qs.annotate(
@@ -785,7 +789,7 @@ def _get_allowed_scope_queryset(
785789
queryset_builder: callable,
786790
extract_ids: callable,
787791
search: str = "",
788-
org: str = "",
792+
orgs: set[str] | None = None,
789793
) -> QuerySet:
790794
"""Resolve allowed scopes from Casbin and return a filtered queryset.
791795
@@ -811,7 +815,7 @@ def _get_allowed_scope_queryset(
811815
specific_scopes = [s for s in allowed_scopes if not isinstance(s, glob_cls)]
812816
allowed_ids = extract_ids(specific_scopes)
813817
allowed_orgs = {s.org for s in allowed_scopes if isinstance(s, glob_cls)}
814-
return queryset_builder(allowed_ids, allowed_orgs, search=search, org=org)
818+
return queryset_builder(allowed_ids, allowed_orgs, search=search, orgs=orgs)
815819

816820
def _build_queryset(self, courses_qs: QuerySet | None, libraries_qs: QuerySet | None) -> QuerySet:
817821
"""Union the provided querysets and sort deterministically.
@@ -834,17 +838,24 @@ def get_queryset(self) -> QuerySet:
834838
scope_type = params_serializer.validated_data["scope_type"]
835839
search = params_serializer.validated_data["search"]
836840
org = params_serializer.validated_data.get("org", "")
841+
orgs_param = params_serializer.validated_data.get("orgs", [])
842+
843+
orgs = set()
844+
orgs.update(orgs_param)
845+
846+
if org:
847+
orgs.add(org)
837848

838849
# Staff and superusers can see all scopes, skip permission filtering.
839850
if user.is_staff or user.is_superuser:
840851
return self._build_queryset(
841852
courses_qs=(
842-
self._get_courses_queryset(search=search, org=org)
853+
self._get_courses_queryset(search=search, orgs=orgs)
843854
if scope_type != ScopesTypeField.LIBRARY
844855
else None
845856
),
846857
libraries_qs=(
847-
self._get_libraries_queryset(search=search, org=org)
858+
self._get_libraries_queryset(search=search, orgs=orgs)
848859
if scope_type != ScopesTypeField.COURSE
849860
else None
850861
),
@@ -867,7 +878,7 @@ def get_permission(scope_cls):
867878
queryset_builder=self._get_courses_queryset,
868879
extract_ids=lambda scopes: {s.external_key for s in scopes},
869880
search=search,
870-
org=org,
881+
orgs=orgs,
871882
)
872883

873884
libraries_qs = None
@@ -882,7 +893,7 @@ def get_permission(scope_cls):
882893
(s.external_key.split(":")[1], s.external_key.split(":")[2]) for s in scopes
883894
},
884895
search=search,
885-
org=org,
896+
orgs=orgs,
886897
)
887898

888899
# Union the requested querysets and sort by org at the DB level.

openedx_authz/tests/rest_api/test_views.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1582,6 +1582,174 @@ def test_org_filter_combined_with_search(self):
15821582
self.assertIn(self.COURSE_ORG1, external_keys)
15831583
self.assertNotIn(self.COURSE_ORG2, external_keys)
15841584

1585+
# ------------------------------------------------------------------ #
1586+
# Orgs filter #
1587+
# ------------------------------------------------------------------ #
1588+
1589+
def test_orgs_filter_staff_courses(self):
1590+
"""Staff user with orgs param sees only courses from that org."""
1591+
self.build_qs_patcher.stop()
1592+
1593+
response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "course"})
1594+
1595+
self.build_qs_patcher.start()
1596+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1597+
for item in response.data["results"]:
1598+
self.assertIn("Org1", item["external_key"])
1599+
# Org2 course should not appear
1600+
external_keys = [item["external_key"] for item in response.data["results"]]
1601+
self.assertNotIn(self.COURSE_ORG2, external_keys)
1602+
1603+
def test_orgs_filter_staff_libraries(self):
1604+
"""Staff user with orgs param sees only libraries from that org."""
1605+
self.build_qs_patcher.stop()
1606+
1607+
response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "library"})
1608+
1609+
self.build_qs_patcher.start()
1610+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1611+
external_keys = [item["external_key"] for item in response.data["results"]]
1612+
self.assertIn(self.LIBRARY_ORG2, external_keys)
1613+
self.assertNotIn(self.LIBRARY_ORG1, external_keys)
1614+
1615+
def test_orgs_filter_staff_no_match(self):
1616+
"""Staff user with orgs param for a non-existent org gets empty results."""
1617+
self.build_qs_patcher.stop()
1618+
1619+
response = self.client.get(self.url, {"orgs": "NonExistentOrg", "scope_type": "course"})
1620+
1621+
self.build_qs_patcher.start()
1622+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1623+
self.assertEqual(response.data["count"], 0)
1624+
1625+
def test_orgs_filter_non_staff_with_permission(self):
1626+
"""Non-staff user with orgs param sees scopes only if they have permission for that org."""
1627+
# regular_1 has LIBRARY_USER on lib:Org1:LIB1 → VIEW_LIBRARY_TEAM granted
1628+
user = User.objects.get(username="regular_1")
1629+
self.client.force_authenticate(user=user)
1630+
self.build_qs_patcher.stop()
1631+
1632+
response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "library"})
1633+
1634+
self.build_qs_patcher.start()
1635+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1636+
external_keys = [item["external_key"] for item in response.data["results"]]
1637+
self.assertIn(self.LIBRARY_ORG1, external_keys)
1638+
1639+
def test_orgs_filter_non_staff_without_permission(self):
1640+
"""Non-staff user with org param for an org they have no permission for gets empty results."""
1641+
# regular_1 has no permissions on Org2
1642+
user = User.objects.get(username="regular_1")
1643+
self.client.force_authenticate(user=user)
1644+
self.build_qs_patcher.stop()
1645+
1646+
response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "library"})
1647+
1648+
self.build_qs_patcher.start()
1649+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1650+
self.assertEqual(response.data["count"], 0)
1651+
1652+
def test_orgs_filter_non_staff_courses(self):
1653+
"""Non-staff user with orgs param sees only courses from that org if they have permission."""
1654+
# regular_9 has COURSE_STAFF on COURSE_ORG1 → VIEW_COURSE_TEAM granted
1655+
user = User.objects.get(username="regular_9")
1656+
self.client.force_authenticate(user=user)
1657+
self.build_qs_patcher.stop()
1658+
1659+
response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "course"})
1660+
1661+
self.build_qs_patcher.start()
1662+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1663+
external_keys = [item["external_key"] for item in response.data["results"]]
1664+
self.assertIn(self.COURSE_ORG1, external_keys)
1665+
1666+
def test_orgs_filter_non_staff_courses_no_permission(self):
1667+
"""Non-staff user with orgs param for an org they have no course permission for gets empty results."""
1668+
# regular_9 has no course permissions on Org2
1669+
user = User.objects.get(username="regular_9")
1670+
self.client.force_authenticate(user=user)
1671+
self.build_qs_patcher.stop()
1672+
1673+
response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "course"})
1674+
1675+
self.build_qs_patcher.start()
1676+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1677+
self.assertEqual(response.data["count"], 0)
1678+
1679+
def test_orgs_filter_with_glob_permission(self):
1680+
"""Non-staff user with orgs glob permission and org filter sees only that org's scopes."""
1681+
user = User.objects.get(username="regular_1")
1682+
self.client.force_authenticate(user=user)
1683+
self.build_qs_patcher.stop()
1684+
1685+
glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*")
1686+
with patch(
1687+
"openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission",
1688+
return_value=[glob_scope],
1689+
):
1690+
response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "library"})
1691+
1692+
self.build_qs_patcher.start()
1693+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1694+
external_keys = [item["external_key"] for item in response.data["results"]]
1695+
self.assertIn(self.LIBRARY_ORG1, external_keys)
1696+
self.assertNotIn(self.LIBRARY_ORG2, external_keys)
1697+
1698+
def test_orgs_filter_with_glob_permission_wrong_org(self):
1699+
"""Non-staff user with org glob for Org1 but filtering by Org2 gets empty results."""
1700+
user = User.objects.get(username="regular_1")
1701+
self.client.force_authenticate(user=user)
1702+
self.build_qs_patcher.stop()
1703+
1704+
glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*")
1705+
with patch(
1706+
"openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission",
1707+
return_value=[glob_scope],
1708+
):
1709+
response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "library"})
1710+
1711+
self.build_qs_patcher.start()
1712+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1713+
self.assertEqual(response.data["count"], 0)
1714+
1715+
def test_orgs_filter_combined_with_search(self):
1716+
"""Orgs filter works together with search filter."""
1717+
self.build_qs_patcher.stop()
1718+
1719+
response = self.client.get(self.url, {"orgs": "Org1", "search": "Course", "scope_type": "course"})
1720+
1721+
self.build_qs_patcher.start()
1722+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1723+
external_keys = [item["external_key"] for item in response.data["results"]]
1724+
self.assertIn(self.COURSE_ORG1, external_keys)
1725+
self.assertNotIn(self.COURSE_ORG2, external_keys)
1726+
1727+
def test_orgs_filter_combined_with_org(self):
1728+
"""Orgs filter works together with the singluar org filter."""
1729+
self.build_qs_patcher.stop()
1730+
1731+
response = self.client.get(
1732+
self.url, {"org": "Org2", "orgs": "Org1", "search": "Course", "scope_type": "course"}
1733+
)
1734+
1735+
self.build_qs_patcher.start()
1736+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1737+
external_keys = [item["external_key"] for item in response.data["results"]]
1738+
self.assertIn(self.COURSE_ORG1, external_keys)
1739+
self.assertIn(self.COURSE_ORG2, external_keys)
1740+
1741+
def test_orgs_filter_with_multiple_orgs(self):
1742+
"""Orgs filter with multiple orgs returns scopes from both orgs."""
1743+
self.build_qs_patcher.stop()
1744+
1745+
response = self.client.get(self.url, {"orgs": "Org1,Org2", "search": "Course", "scope_type": "course"})
1746+
1747+
self.build_qs_patcher.start()
1748+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1749+
external_keys = [item["external_key"] for item in response.data["results"]]
1750+
self.assertIn(self.COURSE_ORG1, external_keys)
1751+
self.assertIn(self.COURSE_ORG2, external_keys)
1752+
15851753

15861754
@ddt
15871755
class TestAdminConsoleOrgsAPIView(ViewTestMixin):

0 commit comments

Comments
 (0)