Skip to content

Commit b91a835

Browse files
committed
feat: Implement scopes endpoint for admin console
1 parent a936a66 commit b91a835

10 files changed

Lines changed: 1172 additions & 7 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.10.0 - 2026-04-16
18+
******************
19+
20+
Added
21+
=====
22+
23+
* Add ``scopes/`` endpoint to list all scopes (courses and libraries), sorted by org, with search and pagination support.
24+
1725
1.9.0 - 2026-04-14
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.9.0"
7+
__version__ = "1.10.0"
88

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

openedx_authz/api/data.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
from opaque_keys.edx.locator import LibraryLocatorV2
1515
from organizations.models import Organization
1616

17-
from openedx_authz.constants.permissions import COURSES_VIEW_COURSE_TEAM, VIEW_LIBRARY_TEAM
17+
from openedx_authz.constants.permissions import (
18+
COURSES_MANAGE_COURSE_TEAM,
19+
COURSES_VIEW_COURSE_TEAM,
20+
MANAGE_LIBRARY_TEAM,
21+
VIEW_LIBRARY_TEAM,
22+
)
1823
from openedx_authz.data import AUTHZ_POLICY_ATTRIBUTES_SEPARATOR, ActionData, AuthzBaseClass, AuthZData, PermissionData
1924
from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model
2025

@@ -357,6 +362,20 @@ def get_admin_view_permission(cls) -> PermissionData:
357362
"""
358363
raise NotImplementedError("Subclasses must implement get_admin_view_permission method.")
359364

365+
@classmethod
366+
@abstractmethod
367+
def get_admin_manage_permission(cls) -> PermissionData:
368+
"""Get the permission required to manage this scope
369+
370+
This method should be implemented on every ScopeData subclass to define
371+
which permission to check against when a user tries to manage assignations
372+
related to this scope in the Admin Console.
373+
374+
Returns:
375+
PermissionData: The permission required to manage this scope in the admin console.
376+
"""
377+
raise NotImplementedError("Subclasses must implement get_admin_manage_permission method.")
378+
360379
@abstractmethod
361380
def get_object(self) -> Any | None:
362381
"""Retrieve the underlying domain object that this scope represents.
@@ -463,6 +482,15 @@ def get_admin_view_permission(cls) -> PermissionData:
463482
"""
464483
return VIEW_LIBRARY_TEAM
465484

485+
@classmethod
486+
def get_admin_manage_permission(cls) -> PermissionData:
487+
"""Get the permission required to manage this scope
488+
489+
Returns:
490+
PermissionData: The permission required to manage this scope in the admin console.
491+
"""
492+
return MANAGE_LIBRARY_TEAM
493+
466494
def get_object(self) -> ContentLibrary | None:
467495
"""Retrieve the ContentLibrary instance associated with this scope.
468496
@@ -585,6 +613,15 @@ def get_admin_view_permission(cls) -> PermissionData:
585613
"""
586614
return COURSES_VIEW_COURSE_TEAM
587615

616+
@classmethod
617+
def get_admin_manage_permission(cls) -> PermissionData:
618+
"""Get the permission required to manage this scope
619+
620+
Returns:
621+
PermissionData: The permission required to manage this scope in the admin console.
622+
"""
623+
return COURSES_MANAGE_COURSE_TEAM
624+
588625
def get_object(self) -> CourseOverview | None:
589626
"""Retrieve the CourseOverview instance associated with this scope.
590627
@@ -715,6 +752,15 @@ def build_external_key(cls, org: str) -> str:
715752
"""
716753
return f"{cls.NAMESPACE}{EXTERNAL_KEY_SEPARATOR}{org}{cls.ID_SEPARATOR}{GLOBAL_SCOPE_WILDCARD}"
717754

755+
@classmethod
756+
def get_admin_manage_permission(cls) -> PermissionData:
757+
"""Get the permission required to manage this scope
758+
759+
Returns:
760+
PermissionData: The permission required to manage this scope in the admin console.
761+
"""
762+
raise NotImplementedError("Subclasses must implement get_admin_manage_permission method.")
763+
718764
@classmethod
719765
def get_org(cls, external_key: str) -> str | None:
720766
"""Extract the organization identifier from the glob pattern.
@@ -813,6 +859,15 @@ def get_admin_view_permission(cls) -> PermissionData:
813859
"""
814860
return VIEW_LIBRARY_TEAM
815861

862+
@classmethod
863+
def get_admin_manage_permission(cls) -> PermissionData:
864+
"""Get the permission required to manage this scope
865+
866+
Returns:
867+
PermissionData: The permission required to manage this scope in the admin console.
868+
"""
869+
return MANAGE_LIBRARY_TEAM
870+
816871

817872
@define
818873
class OrgCourseOverviewGlobData(OrgGlobData):
@@ -862,6 +917,15 @@ def get_admin_view_permission(cls) -> PermissionData:
862917
"""
863918
return COURSES_VIEW_COURSE_TEAM
864919

920+
@classmethod
921+
def get_admin_manage_permission(cls) -> PermissionData:
922+
"""Get the permission required to manage this scope
923+
924+
Returns:
925+
PermissionData: The permission required to manage this scope in the admin console.
926+
"""
927+
return COURSES_MANAGE_COURSE_TEAM
928+
865929

866930
class CCXCourseOverviewData(CourseOverviewData):
867931
"""CCX course scope for authorization in the Open edX platform.

openedx_authz/rest_api/data.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,19 @@ class RoleOperationError(BaseEnum):
6969
USER_DOES_NOT_HAVE_ROLE = "user_does_not_have_role"
7070
ROLE_ASSIGNMENT_ERROR = "role_assignment_error"
7171
ROLE_REMOVAL_ERROR = "role_removal_error"
72+
73+
74+
class ScopesQuerySetFields(BaseEnum):
75+
"""Enum for the annotated fields used in the Scopes query set for the scopes endpoint"""
76+
77+
SCOPE_ID = "scope_id"
78+
DISPLAY_NAME_COL = "display_name_col"
79+
ORG_NAME = "org_name"
80+
SCOPE_TYPE = "scope_type"
81+
82+
83+
class ScopesTypeField(BaseEnum):
84+
"""Enum for the scope_type query field on the scopes endpoint"""
85+
86+
COURSE = "course"
87+
LIBRARY = "library"

openedx_authz/rest_api/v1/serializers.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
"""Serializers for the Open edX AuthZ REST API."""
22

33
from django.contrib.auth import get_user_model
4+
from opaque_keys.edx.locator import LibraryLocatorV2
5+
from organizations.serializers import OrganizationSerializer
46
from rest_framework import serializers
57

68
from openedx_authz import api
79
from openedx_authz.api.data import UserAssignments
8-
from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder, UserAssignmentSortField
10+
from openedx_authz.rest_api.data import (
11+
AssignmentSortField,
12+
ScopesTypeField,
13+
SortField,
14+
SortOrder,
15+
UserAssignmentSortField,
16+
)
917
from openedx_authz.rest_api.utils import get_generic_scope
1018
from openedx_authz.rest_api.v1.fields import (
1119
CaseSensitiveCommaSeparatedListField,
@@ -49,6 +57,12 @@ class OrderMixin(serializers.Serializer): # pylint: disable=abstract-method
4957
)
5058

5159

60+
class OrgMixin(serializers.Serializer): # pylint: disable=abstract-method
61+
"""Mixin providing org field functionality."""
62+
63+
org = serializers.CharField(required=False, max_length=255)
64+
65+
5266
class PermissionValidationSerializer(ActionMixin, ScopeMixin): # pylint: disable=abstract-method
5367
"""Serializer for permission validation request."""
5468

@@ -215,6 +229,16 @@ def get_roles(self, obj: api.RoleAssignmentData) -> list[str]:
215229
return [role.external_key for role in obj.roles]
216230

217231

232+
class ListScopesQuerySerializer(OrgMixin): # pylint: disable=abstract-method
233+
"""Serializer for validating query parameters in ScopesAPIView."""
234+
235+
management_permission_only = serializers.BooleanField(required=False, default=False)
236+
scope_type = serializers.ChoiceField(
237+
choices=[(e.value, e.name) for e in ScopesTypeField], required=False, default=None, allow_null=True
238+
)
239+
search = serializers.CharField(required=False, default="", allow_blank=True)
240+
241+
218242
class ListTeamMembersSerializer(OrderMixin): # pylint: disable=abstract-method
219243
"""
220244
Serializer for listing team members.
@@ -379,3 +403,28 @@ class ListAssignmentsQuerySerializer(ListTeamMemberAssignmentsQuerySerializer):
379403
choices=[(e.value, e.name) for e in UserAssignmentSortField],
380404
default=UserAssignmentSortField.FULL_NAME,
381405
)
406+
407+
408+
class ScopeSerializer(serializers.Serializer): # pylint: disable=abstract-method
409+
"""
410+
Serializer for scope.
411+
"""
412+
413+
external_key = serializers.SerializerMethodField()
414+
display_name = serializers.SerializerMethodField()
415+
org = serializers.SerializerMethodField()
416+
417+
def get_external_key(self, obj: dict) -> str:
418+
"""Get the external key for the given scope."""
419+
if obj["scope_type"] == ScopesTypeField.LIBRARY:
420+
return str(LibraryLocatorV2(org=obj["org_name"], slug=obj["scope_id"]))
421+
return obj["scope_id"]
422+
423+
def get_display_name(self, obj: dict) -> str:
424+
"""Get the display name for the given scope."""
425+
return str(obj.get("display_name_col") or "")
426+
427+
def get_org(self, obj: dict) -> dict | None:
428+
"""Get the org for the given scope."""
429+
org = self.context.get("org_map", {}).get(obj["org_name"])
430+
return OrganizationSerializer(org).data if org else None

openedx_authz/rest_api/v1/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@
1919
"users/<str:username>/assignments/", views.TeamMemberAssignmentsAPIView.as_view(), name="user-assignment-list"
2020
),
2121
path("assignments/", views.AssignmentsAPIView.as_view(), name="assignment-list"),
22+
path("scopes/", views.ScopesAPIView.as_view(), name="scope-list"),
2223
]

0 commit comments

Comments
 (0)