Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
****************

Expand Down
2 changes: 1 addition & 1 deletion openedx_authz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

import os

__version__ = "1.3.0"
__version__ = "1.4.0"

ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
23 changes: 23 additions & 0 deletions openedx_authz/rest_api/v1/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions openedx_authz/rest_api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
90 changes: 88 additions & 2 deletions openedx_authz/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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()
Comment thread
rodmgwgu marked this conversation as resolved.
@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.
Comment thread
rodmgwgu marked this conversation as resolved.

**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]
195 changes: 195 additions & 0 deletions openedx_authz/tests/rest_api/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Comment thread
rodmgwgu marked this conversation as resolved.


@ddt
class TestRoleListView(ViewTestMixin):
"""Test suite for RoleListView."""
Expand Down