Skip to content

Commit 8fc8733

Browse files
authored
feat: Implement orgs endpoint for admin console (#250)
1 parent 9b3c787 commit 8fc8733

File tree

6 files changed

+313
-3
lines changed

6 files changed

+313
-3
lines changed

CHANGELOG.rst

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

17+
1.4.0 - 2026-04-09
18+
******************
19+
20+
* Add ``orgs/`` endpoint to list and search orgs, with pagination, as required for filters in the Admin Console.
21+
1722
1.3.0 2026-04-08
1823
****************
1924

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.3.0"
7+
__version__ = "1.4.0"
88

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

openedx_authz/rest_api/v1/permissions.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,29 @@ def validate_permissions(self, request, permissions: list[str], scope_value: str
259259
return True
260260

261261

262+
class AnyScopePermission(MethodPermissionMixin, BasePermission):
263+
"""Permission handler for endpoints that are not tied to a specific scope.
264+
265+
Grants access if the user has at least one of the required permissions in any scope.
266+
"""
267+
268+
def has_permission(self, request, view) -> bool:
269+
"""Check if the user has any of the required permissions across all scopes.
270+
271+
Superusers and staff are automatically granted access. For other users,
272+
grants access if the user has at least one required permission in any scope.
273+
274+
Returns:
275+
bool: True if the user has at least one required permission in any scope.
276+
"""
277+
if request.user.is_superuser or request.user.is_staff:
278+
return True
279+
required = self.get_required_permissions(request, view)
280+
if not required:
281+
return False
282+
return any(api.get_scopes_for_user_and_permission(request.user.username, permission) for permission in required)
283+
284+
262285
class ContentLibraryPermission(MethodPermissionMixin, BaseScopePermission):
263286
"""Permission handler for content library scopes.
264287

openedx_authz/rest_api/v1/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
),
1313
path("roles/", views.RoleListView.as_view(), name="role-list"),
1414
path("roles/users/", views.RoleUserAPIView.as_view(), name="role-user-list"),
15+
path("orgs/", views.AdminConsoleOrgsAPIView.as_view(), name="orgs-list"),
1516
]

openedx_authz/rest_api/v1/views.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
import edx_api_doc_tools as apidocs
1111
from django.contrib.auth import get_user_model
1212
from django.http import HttpRequest
13-
from rest_framework import status
13+
from django.utils.decorators import method_decorator
14+
from edx_api_doc_tools import schema_for
15+
from organizations.models import Organization
16+
from organizations.serializers import OrganizationSerializer
17+
from rest_framework import filters, generics, status
1418
from rest_framework.response import Response
1519
from rest_framework.views import APIView
1620

@@ -26,7 +30,7 @@
2630
sort_users,
2731
)
2832
from openedx_authz.rest_api.v1.paginators import AuthZAPIViewPagination
29-
from openedx_authz.rest_api.v1.permissions import DynamicScopePermission
33+
from openedx_authz.rest_api.v1.permissions import AnyScopePermission, DynamicScopePermission
3034
from openedx_authz.rest_api.v1.serializers import (
3135
AddUsersToRoleWithScopeSerializer,
3236
ListRolesWithScopeResponseSerializer,
@@ -449,3 +453,85 @@ def get(self, request: HttpRequest) -> Response:
449453
paginated_response_data = paginator.paginate_queryset(response_data, request)
450454
serialized_data = ListRolesWithScopeResponseSerializer(paginated_response_data, many=True)
451455
return paginator.get_paginated_response(serialized_data.data)
456+
457+
458+
@view_auth_classes()
459+
@method_decorator(
460+
authz_permissions(
461+
[
462+
permissions.VIEW_LIBRARY_TEAM.identifier,
463+
permissions.COURSES_VIEW_COURSE_TEAM.identifier,
464+
]
465+
),
466+
name="get",
467+
)
468+
@schema_for(
469+
"get",
470+
parameters=[
471+
apidocs.query_parameter("search", str, description="Filter orgs by name or short_name"),
472+
apidocs.query_parameter("page", int, description="Page number for pagination"),
473+
apidocs.query_parameter("page_size", int, description="Number of items per page"),
474+
],
475+
responses={
476+
status.HTTP_200_OK: OrganizationSerializer(many=True),
477+
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated",
478+
},
479+
)
480+
class AdminConsoleOrgsAPIView(generics.ListAPIView):
481+
"""
482+
API view for listing orgs
483+
This API is used on the filters functionality on the Admin Console.
484+
485+
**Endpoints**
486+
487+
- GET: Retrieve all organizations
488+
489+
**Query Parameters**
490+
491+
- search (Optional): Search term to filter organizations by name or short name
492+
- page (Optional): Page number for pagination
493+
- page_size (Optional): Number of items per page
494+
495+
**Response Format**
496+
497+
Returns a paginated list of organization objects, each containing:
498+
499+
- id: The organization's ID
500+
- name: The organization's name
501+
- short_name: The organization's short name
502+
503+
**Authentication and Permissions**
504+
505+
- Requires authenticated user.
506+
507+
**Example Request**
508+
509+
GET /api/authz/v1/orgs/?search=edx&page=1&page_size=10
510+
511+
**Example Response**::
512+
513+
{
514+
"count": 1,
515+
"next": null,
516+
"previous": null,
517+
"results": [
518+
{
519+
"id": 1,
520+
"created": "2026-04-02T19:30:36.779095Z",
521+
"modified": "2026-04-02T19:30:36.779095Z",
522+
"name": "OpenedX",
523+
"short_name": "OpenedX",
524+
"description": "",
525+
"logo": null,
526+
"active": true
527+
}
528+
]
529+
}
530+
"""
531+
532+
queryset = Organization.objects.filter(active=True).order_by("name")
533+
serializer_class = OrganizationSerializer
534+
pagination_class = AuthZAPIViewPagination
535+
filter_backends = [filters.SearchFilter]
536+
search_fields = ["name", "short_name"]
537+
permission_classes = [AnyScopePermission]

openedx_authz/tests/rest_api/test_views.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ddt import data, ddt, unpack
1212
from django.contrib.auth import get_user_model
1313
from django.urls import reverse
14+
from organizations.models import Organization
1415
from rest_framework import status
1516
from rest_framework.test import APIClient
1617

@@ -853,6 +854,200 @@ def test_put_accepts_valid_full_course_key_scope(self, _mock_exists, _mock_assig
853854
self.assertEqual(len(response.data["completed"]), 1)
854855

855856

857+
@ddt
858+
class TestAdminConsoleOrgsAPIView(ViewTestMixin):
859+
"""Test suite for AdminConsoleOrgsAPIView."""
860+
861+
@classmethod
862+
def setUpClass(cls):
863+
"""Assign a course role to regular_9 for COURSES_VIEW_COURSE_TEAM permission tests."""
864+
super().setUpClass()
865+
cls._assign_roles_to_users(
866+
[
867+
{
868+
"subject_name": "regular_9",
869+
"role_name": roles.COURSE_STAFF.external_key,
870+
"scope_name": "course-v1:Org1+COURSE1+2024",
871+
},
872+
]
873+
)
874+
875+
@classmethod
876+
def setUpTestData(cls):
877+
"""Create Organization fixtures."""
878+
super().setUpTestData()
879+
880+
Organization.objects.bulk_create(
881+
[
882+
Organization(name="Alpha University", short_name="AlphaU"),
883+
Organization(name="Beta Institute", short_name="BetaI"),
884+
Organization(name="Gamma College", short_name="GammaC"),
885+
]
886+
)
887+
888+
def setUp(self):
889+
"""Set up test fixtures."""
890+
super().setUp()
891+
self.url = reverse("openedx_authz:orgs-list")
892+
893+
def test_get_orgs_returns_all(self):
894+
"""Test that all orgs are returned when no search param is provided.
895+
896+
Expected result:
897+
- Returns 200 OK status
898+
- Returns all 3 orgs
899+
"""
900+
response = self.client.get(self.url)
901+
902+
self.assertEqual(response.status_code, status.HTTP_200_OK)
903+
self.assertEqual(response.data["count"], 3)
904+
self.assertEqual(len(response.data["results"]), 3)
905+
906+
@data(
907+
# Match by name
908+
("Alpha", 1),
909+
("university", 1),
910+
# Match by short_name
911+
("BetaI", 1),
912+
("gamma", 1),
913+
# Partial match across multiple orgs
914+
("a", 3),
915+
# No match
916+
("nonexistent", 0),
917+
)
918+
@unpack
919+
def test_get_orgs_search(self, search_term: str, expected_count: int):
920+
"""Test filtering orgs by name or short_name via the search param.
921+
922+
Expected result:
923+
- Returns 200 OK status
924+
- Returns only orgs matching the search term
925+
"""
926+
response = self.client.get(self.url, {"search": search_term})
927+
928+
self.assertEqual(response.status_code, status.HTTP_200_OK)
929+
self.assertEqual(response.data["count"], expected_count)
930+
self.assertEqual(len(response.data["results"]), expected_count)
931+
932+
@data(
933+
({}, 3, False),
934+
({"page": 1, "page_size": 2}, 2, True),
935+
({"page": 2, "page_size": 2}, 1, False),
936+
({"page": 1, "page_size": 3}, 3, False),
937+
)
938+
@unpack
939+
def test_get_orgs_pagination(self, query_params: dict, expected_count: int, has_next: bool):
940+
"""Test pagination of org results.
941+
942+
Expected result:
943+
- Returns 200 OK status
944+
- Returns correct page size and next link
945+
"""
946+
response = self.client.get(self.url, query_params)
947+
948+
self.assertEqual(response.status_code, status.HTTP_200_OK)
949+
self.assertEqual(len(response.data["results"]), expected_count)
950+
if has_next:
951+
self.assertIsNotNone(response.data["next"])
952+
else:
953+
self.assertIsNone(response.data["next"])
954+
955+
def test_get_orgs_response_shape(self):
956+
"""Test that each org result contains the expected fields.
957+
958+
Expected result:
959+
- Each result has id, name, and short_name fields
960+
"""
961+
response = self.client.get(self.url)
962+
963+
self.assertEqual(response.status_code, status.HTTP_200_OK)
964+
result = response.data["results"][0]
965+
self.assertIn("id", result)
966+
self.assertIn("name", result)
967+
self.assertIn("short_name", result)
968+
969+
def test_get_orgs_excludes_inactive(self):
970+
"""Test that inactive orgs are not returned.
971+
972+
Expected result:
973+
- Returns 200 OK status
974+
- Inactive orgs are excluded from results
975+
"""
976+
Organization.objects.create(name="Inactive Org", short_name="InactiveO", active=False)
977+
978+
response = self.client.get(self.url)
979+
980+
self.assertEqual(response.status_code, status.HTTP_200_OK)
981+
self.assertEqual(response.data["count"], 3)
982+
result_names = [org["name"] for org in response.data["results"]]
983+
self.assertNotIn("Inactive Org", result_names)
984+
985+
@data(
986+
# Only VIEW_LIBRARY_TEAM (library_user role in a lib scope)
987+
("regular_1", status.HTTP_200_OK),
988+
# Only COURSES_VIEW_COURSE_TEAM (course_staff role in a course scope)
989+
("regular_9", status.HTTP_200_OK),
990+
# No relevant permissions
991+
("regular_10", status.HTTP_403_FORBIDDEN),
992+
# Superuser
993+
("admin_1", status.HTTP_200_OK),
994+
)
995+
@unpack
996+
def test_get_orgs_permissions(self, username: str, expected_status: int):
997+
"""Test access control for AdminConsoleOrgsAPIView.
998+
999+
Test cases:
1000+
- User with only VIEW_LIBRARY_TEAM (via library role): allowed
1001+
- User with only COURSES_VIEW_COURSE_TEAM (via course role): allowed
1002+
- User with neither permission: forbidden
1003+
- Superuser/staff: allowed
1004+
1005+
Expected result:
1006+
- Returns appropriate status code based on user permissions
1007+
"""
1008+
user = User.objects.get(username=username)
1009+
self.client.force_authenticate(user=user)
1010+
1011+
response = self.client.get(self.url)
1012+
1013+
self.assertEqual(response.status_code, expected_status)
1014+
1015+
def test_get_orgs_user_with_both_permissions_allowed(self):
1016+
"""Test that a user with both VIEW_LIBRARY_TEAM and COURSES_VIEW_COURSE_TEAM can access the endpoint.
1017+
1018+
Expected result:
1019+
- Returns 200 OK status
1020+
"""
1021+
# regular_1 has library_user (VIEW_LIBRARY_TEAM); assign a course role too
1022+
self._assign_roles_to_users(
1023+
[
1024+
{
1025+
"subject_name": "regular_1",
1026+
"role_name": roles.COURSE_STAFF.external_key,
1027+
"scope_name": "course-v1:Org1+COURSE1+2024",
1028+
},
1029+
]
1030+
)
1031+
user = User.objects.get(username="regular_1")
1032+
self.client.force_authenticate(user=user)
1033+
1034+
response = self.client.get(self.url)
1035+
1036+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1037+
1038+
def test_get_orgs_unauthenticated(self):
1039+
"""Test that unauthenticated requests are rejected.
1040+
1041+
Expected result:
1042+
- Returns 401 UNAUTHORIZED status
1043+
"""
1044+
self.client.force_authenticate(user=None)
1045+
1046+
response = self.client.get(self.url)
1047+
1048+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
1049+
1050+
8561051
@ddt
8571052
class TestRoleListView(ViewTestMixin):
8581053
"""Test suite for RoleListView."""

0 commit comments

Comments
 (0)