From 21b2b0d6fe1bf625f9e2056a2a1c83f90b162055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20M=C3=A9ndez?= Date: Tue, 7 Apr 2026 16:27:56 -0600 Subject: [PATCH] feat: Implement orgs endpoint for admin console --- CHANGELOG.rst | 5 + openedx_authz/__init__.py | 2 +- openedx_authz/rest_api/v1/permissions.py | 23 +++ openedx_authz/rest_api/v1/urls.py | 1 + openedx_authz/rest_api/v1/views.py | 90 +++++++++- openedx_authz/tests/rest_api/test_views.py | 195 +++++++++++++++++++++ 6 files changed, 313 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 83367fcf..3de9fd8d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,11 @@ Change Log Unreleased ********** +1.4.0 - 2026-04-09 +****************** + +* Add ``orgs/`` endpoint to list and search orgs, with pagination, as required for filters in the Admin Console. + 1.3.0 2026-04-08 **************** diff --git a/openedx_authz/__init__.py b/openedx_authz/__init__.py index 1d8b0871..f897dc2c 100644 --- a/openedx_authz/__init__.py +++ b/openedx_authz/__init__.py @@ -4,6 +4,6 @@ import os -__version__ = "1.3.0" +__version__ = "1.4.0" ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) diff --git a/openedx_authz/rest_api/v1/permissions.py b/openedx_authz/rest_api/v1/permissions.py index d867b84f..8d5c18f7 100644 --- a/openedx_authz/rest_api/v1/permissions.py +++ b/openedx_authz/rest_api/v1/permissions.py @@ -259,6 +259,29 @@ def validate_permissions(self, request, permissions: list[str], scope_value: str return True +class AnyScopePermission(MethodPermissionMixin, BasePermission): + """Permission handler for endpoints that are not tied to a specific scope. + + Grants access if the user has at least one of the required permissions in any scope. + """ + + def has_permission(self, request, view) -> bool: + """Check if the user has any of the required permissions across all scopes. + + Superusers and staff are automatically granted access. For other users, + grants access if the user has at least one required permission in any scope. + + Returns: + bool: True if the user has at least one required permission in any scope. + """ + if request.user.is_superuser or request.user.is_staff: + return True + required = self.get_required_permissions(request, view) + if not required: + return False + return any(api.get_scopes_for_user_and_permission(request.user.username, permission) for permission in required) + + class ContentLibraryPermission(MethodPermissionMixin, BaseScopePermission): """Permission handler for content library scopes. diff --git a/openedx_authz/rest_api/v1/urls.py b/openedx_authz/rest_api/v1/urls.py index b6e7c3fa..761e7f6f 100644 --- a/openedx_authz/rest_api/v1/urls.py +++ b/openedx_authz/rest_api/v1/urls.py @@ -12,4 +12,5 @@ ), path("roles/", views.RoleListView.as_view(), name="role-list"), path("roles/users/", views.RoleUserAPIView.as_view(), name="role-user-list"), + path("orgs/", views.AdminConsoleOrgsAPIView.as_view(), name="orgs-list"), ] diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index e560bc08..c6947967 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -10,7 +10,11 @@ import edx_api_doc_tools as apidocs from django.contrib.auth import get_user_model from django.http import HttpRequest -from rest_framework import status +from django.utils.decorators import method_decorator +from edx_api_doc_tools import schema_for +from organizations.models import Organization +from organizations.serializers import OrganizationSerializer +from rest_framework import filters, generics, status from rest_framework.response import Response from rest_framework.views import APIView @@ -26,7 +30,7 @@ sort_users, ) from openedx_authz.rest_api.v1.paginators import AuthZAPIViewPagination -from openedx_authz.rest_api.v1.permissions import DynamicScopePermission +from openedx_authz.rest_api.v1.permissions import AnyScopePermission, DynamicScopePermission from openedx_authz.rest_api.v1.serializers import ( AddUsersToRoleWithScopeSerializer, ListRolesWithScopeResponseSerializer, @@ -449,3 +453,85 @@ def get(self, request: HttpRequest) -> Response: paginated_response_data = paginator.paginate_queryset(response_data, request) serialized_data = ListRolesWithScopeResponseSerializer(paginated_response_data, many=True) return paginator.get_paginated_response(serialized_data.data) + + +@view_auth_classes() +@method_decorator( + authz_permissions( + [ + permissions.VIEW_LIBRARY_TEAM.identifier, + permissions.COURSES_VIEW_COURSE_TEAM.identifier, + ] + ), + name="get", +) +@schema_for( + "get", + parameters=[ + apidocs.query_parameter("search", str, description="Filter orgs by name or short_name"), + apidocs.query_parameter("page", int, description="Page number for pagination"), + apidocs.query_parameter("page_size", int, description="Number of items per page"), + ], + responses={ + status.HTTP_200_OK: OrganizationSerializer(many=True), + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + }, +) +class AdminConsoleOrgsAPIView(generics.ListAPIView): + """ + API view for listing orgs + This API is used on the filters functionality on the Admin Console. + + **Endpoints** + + - GET: Retrieve all organizations + + **Query Parameters** + + - search (Optional): Search term to filter organizations by name or short name + - page (Optional): Page number for pagination + - page_size (Optional): Number of items per page + + **Response Format** + + Returns a paginated list of organization objects, each containing: + + - id: The organization's ID + - name: The organization's name + - short_name: The organization's short name + + **Authentication and Permissions** + + - Requires authenticated user. + + **Example Request** + + GET /api/authz/v1/orgs/?search=edx&page=1&page_size=10 + + **Example Response**:: + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "created": "2026-04-02T19:30:36.779095Z", + "modified": "2026-04-02T19:30:36.779095Z", + "name": "OpenedX", + "short_name": "OpenedX", + "description": "", + "logo": null, + "active": true + } + ] + } + """ + + queryset = Organization.objects.filter(active=True).order_by("name") + serializer_class = OrganizationSerializer + pagination_class = AuthZAPIViewPagination + filter_backends = [filters.SearchFilter] + search_fields = ["name", "short_name"] + permission_classes = [AnyScopePermission] diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index 3f8520b8..e3d12223 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -11,6 +11,7 @@ from ddt import data, ddt, unpack from django.contrib.auth import get_user_model from django.urls import reverse +from organizations.models import Organization from rest_framework import status from rest_framework.test import APIClient @@ -853,6 +854,200 @@ def test_put_accepts_valid_full_course_key_scope(self, _mock_exists, _mock_assig self.assertEqual(len(response.data["completed"]), 1) +@ddt +class TestAdminConsoleOrgsAPIView(ViewTestMixin): + """Test suite for AdminConsoleOrgsAPIView.""" + + @classmethod + def setUpClass(cls): + """Assign a course role to regular_9 for COURSES_VIEW_COURSE_TEAM permission tests.""" + super().setUpClass() + cls._assign_roles_to_users( + [ + { + "subject_name": "regular_9", + "role_name": roles.COURSE_STAFF.external_key, + "scope_name": "course-v1:Org1+COURSE1+2024", + }, + ] + ) + + @classmethod + def setUpTestData(cls): + """Create Organization fixtures.""" + super().setUpTestData() + + Organization.objects.bulk_create( + [ + Organization(name="Alpha University", short_name="AlphaU"), + Organization(name="Beta Institute", short_name="BetaI"), + Organization(name="Gamma College", short_name="GammaC"), + ] + ) + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.url = reverse("openedx_authz:orgs-list") + + def test_get_orgs_returns_all(self): + """Test that all orgs are returned when no search param is provided. + + Expected result: + - Returns 200 OK status + - Returns all 3 orgs + """ + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + self.assertEqual(len(response.data["results"]), 3) + + @data( + # Match by name + ("Alpha", 1), + ("university", 1), + # Match by short_name + ("BetaI", 1), + ("gamma", 1), + # Partial match across multiple orgs + ("a", 3), + # No match + ("nonexistent", 0), + ) + @unpack + def test_get_orgs_search(self, search_term: str, expected_count: int): + """Test filtering orgs by name or short_name via the search param. + + Expected result: + - Returns 200 OK status + - Returns only orgs matching the search term + """ + response = self.client.get(self.url, {"search": search_term}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + self.assertEqual(len(response.data["results"]), expected_count) + + @data( + ({}, 3, False), + ({"page": 1, "page_size": 2}, 2, True), + ({"page": 2, "page_size": 2}, 1, False), + ({"page": 1, "page_size": 3}, 3, False), + ) + @unpack + def test_get_orgs_pagination(self, query_params: dict, expected_count: int, has_next: bool): + """Test pagination of org results. + + Expected result: + - Returns 200 OK status + - Returns correct page size and next link + """ + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), expected_count) + if has_next: + self.assertIsNotNone(response.data["next"]) + else: + self.assertIsNone(response.data["next"]) + + def test_get_orgs_response_shape(self): + """Test that each org result contains the expected fields. + + Expected result: + - Each result has id, name, and short_name fields + """ + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + result = response.data["results"][0] + self.assertIn("id", result) + self.assertIn("name", result) + self.assertIn("short_name", result) + + def test_get_orgs_excludes_inactive(self): + """Test that inactive orgs are not returned. + + Expected result: + - Returns 200 OK status + - Inactive orgs are excluded from results + """ + Organization.objects.create(name="Inactive Org", short_name="InactiveO", active=False) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + result_names = [org["name"] for org in response.data["results"]] + self.assertNotIn("Inactive Org", result_names) + + @data( + # Only VIEW_LIBRARY_TEAM (library_user role in a lib scope) + ("regular_1", status.HTTP_200_OK), + # Only COURSES_VIEW_COURSE_TEAM (course_staff role in a course scope) + ("regular_9", status.HTTP_200_OK), + # No relevant permissions + ("regular_10", status.HTTP_403_FORBIDDEN), + # Superuser + ("admin_1", status.HTTP_200_OK), + ) + @unpack + def test_get_orgs_permissions(self, username: str, expected_status: int): + """Test access control for AdminConsoleOrgsAPIView. + + Test cases: + - User with only VIEW_LIBRARY_TEAM (via library role): allowed + - User with only COURSES_VIEW_COURSE_TEAM (via course role): allowed + - User with neither permission: forbidden + - Superuser/staff: allowed + + Expected result: + - Returns appropriate status code based on user permissions + """ + user = User.objects.get(username=username) + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, expected_status) + + def test_get_orgs_user_with_both_permissions_allowed(self): + """Test that a user with both VIEW_LIBRARY_TEAM and COURSES_VIEW_COURSE_TEAM can access the endpoint. + + Expected result: + - Returns 200 OK status + """ + # regular_1 has library_user (VIEW_LIBRARY_TEAM); assign a course role too + self._assign_roles_to_users( + [ + { + "subject_name": "regular_1", + "role_name": roles.COURSE_STAFF.external_key, + "scope_name": "course-v1:Org1+COURSE1+2024", + }, + ] + ) + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_get_orgs_unauthenticated(self): + """Test that unauthenticated requests are rejected. + + Expected result: + - Returns 401 UNAUTHORIZED status + """ + self.client.force_authenticate(user=None) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + @ddt class TestRoleListView(ViewTestMixin): """Test suite for RoleListView."""