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
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ Change Log
Unreleased
**********

1.10.0 - 2026-04-16
*******************

Added
=====

* Add ``scopes/`` endpoint to list all scopes (courses and libraries), sorted by org, with search and pagination support.

1.9.0 - 2026-04-14
******************

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.9.0"
__version__ = "1.10.0"

ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
66 changes: 65 additions & 1 deletion openedx_authz/api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
from opaque_keys.edx.locator import LibraryLocatorV2
from organizations.models import Organization

from openedx_authz.constants.permissions import COURSES_VIEW_COURSE_TEAM, VIEW_LIBRARY_TEAM
from openedx_authz.constants.permissions import (
COURSES_MANAGE_COURSE_TEAM,
COURSES_VIEW_COURSE_TEAM,
MANAGE_LIBRARY_TEAM,
VIEW_LIBRARY_TEAM,
)
from openedx_authz.data import AUTHZ_POLICY_ATTRIBUTES_SEPARATOR, ActionData, AuthzBaseClass, AuthZData, PermissionData
from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model

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

@classmethod
@abstractmethod
def get_admin_manage_permission(cls) -> PermissionData:
"""Get the permission required to manage this scope

This method should be implemented on every ScopeData subclass to define
which permission to check against when a user tries to manage assignations
related to this scope in the Admin Console.

Returns:
PermissionData: The permission required to manage this scope in the admin console.
"""
raise NotImplementedError("Subclasses must implement get_admin_manage_permission method.")

@abstractmethod
def get_object(self) -> Any | None:
"""Retrieve the underlying domain object that this scope represents.
Expand Down Expand Up @@ -463,6 +482,15 @@ def get_admin_view_permission(cls) -> PermissionData:
"""
return VIEW_LIBRARY_TEAM

@classmethod
def get_admin_manage_permission(cls) -> PermissionData:
"""Get the permission required to manage this scope

Returns:
PermissionData: The permission required to manage this scope in the admin console.
"""
return MANAGE_LIBRARY_TEAM

def get_object(self) -> ContentLibrary | None:
"""Retrieve the ContentLibrary instance associated with this scope.

Expand Down Expand Up @@ -585,6 +613,15 @@ def get_admin_view_permission(cls) -> PermissionData:
"""
return COURSES_VIEW_COURSE_TEAM

@classmethod
def get_admin_manage_permission(cls) -> PermissionData:
"""Get the permission required to manage this scope

Returns:
PermissionData: The permission required to manage this scope in the admin console.
"""
return COURSES_MANAGE_COURSE_TEAM

def get_object(self) -> CourseOverview | None:
"""Retrieve the CourseOverview instance associated with this scope.

Expand Down Expand Up @@ -715,6 +752,15 @@ def build_external_key(cls, org: str) -> str:
"""
return f"{cls.NAMESPACE}{EXTERNAL_KEY_SEPARATOR}{org}{cls.ID_SEPARATOR}{GLOBAL_SCOPE_WILDCARD}"

@classmethod
def get_admin_manage_permission(cls) -> PermissionData:
"""Get the permission required to manage this scope

Returns:
PermissionData: The permission required to manage this scope in the admin console.
"""
raise NotImplementedError("Subclasses must implement get_admin_manage_permission method.")

@classmethod
def get_org(cls, external_key: str) -> str | None:
"""Extract the organization identifier from the glob pattern.
Expand Down Expand Up @@ -813,6 +859,15 @@ def get_admin_view_permission(cls) -> PermissionData:
"""
return VIEW_LIBRARY_TEAM

@classmethod
def get_admin_manage_permission(cls) -> PermissionData:
"""Get the permission required to manage this scope

Returns:
PermissionData: The permission required to manage this scope in the admin console.
"""
return MANAGE_LIBRARY_TEAM


@define
class OrgCourseOverviewGlobData(OrgGlobData):
Expand Down Expand Up @@ -862,6 +917,15 @@ def get_admin_view_permission(cls) -> PermissionData:
"""
return COURSES_VIEW_COURSE_TEAM

@classmethod
def get_admin_manage_permission(cls) -> PermissionData:
"""Get the permission required to manage this scope

Returns:
PermissionData: The permission required to manage this scope in the admin console.
"""
return COURSES_MANAGE_COURSE_TEAM


class CCXCourseOverviewData(CourseOverviewData):
"""CCX course scope for authorization in the Open edX platform.
Expand Down
16 changes: 16 additions & 0 deletions openedx_authz/rest_api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,19 @@ class RoleOperationError(BaseEnum):
USER_DOES_NOT_HAVE_ROLE = "user_does_not_have_role"
ROLE_ASSIGNMENT_ERROR = "role_assignment_error"
ROLE_REMOVAL_ERROR = "role_removal_error"


class ScopesQuerySetFields(BaseEnum):
"""Enum for the annotated fields used in the Scopes query set for the scopes endpoint"""

SCOPE_ID = "scope_id"
DISPLAY_NAME_COL = "display_name_col"
ORG_NAME = "org_name"
SCOPE_TYPE = "scope_type"


class ScopesTypeField(BaseEnum):
"""Enum for the scope_type query field on the scopes endpoint"""

COURSE = "course"
LIBRARY = "library"
51 changes: 50 additions & 1 deletion openedx_authz/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
"""Serializers for the Open edX AuthZ REST API."""

from django.contrib.auth import get_user_model
from opaque_keys.edx.locator import LibraryLocatorV2
from organizations.serializers import OrganizationSerializer
from rest_framework import serializers

from openedx_authz import api
from openedx_authz.api.data import UserAssignments
from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder, UserAssignmentSortField
from openedx_authz.rest_api.data import (
AssignmentSortField,
ScopesTypeField,
SortField,
SortOrder,
UserAssignmentSortField,
)
from openedx_authz.rest_api.utils import get_generic_scope
from openedx_authz.rest_api.v1.fields import (
CaseSensitiveCommaSeparatedListField,
Expand Down Expand Up @@ -49,6 +57,12 @@ class OrderMixin(serializers.Serializer): # pylint: disable=abstract-method
)


class OrgMixin(serializers.Serializer): # pylint: disable=abstract-method
"""Mixin providing org field functionality."""

org = serializers.CharField(required=False, max_length=255)


class PermissionValidationSerializer(ActionMixin, ScopeMixin): # pylint: disable=abstract-method
"""Serializer for permission validation request."""

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


class ListScopesQuerySerializer(OrgMixin): # pylint: disable=abstract-method
"""Serializer for validating query parameters in ScopesAPIView."""

management_permission_only = serializers.BooleanField(required=False, default=False)
scope_type = serializers.ChoiceField(
choices=[(e.value, e.name) for e in ScopesTypeField], required=False, default=None, allow_null=True
)
search = serializers.CharField(required=False, default="", allow_blank=True)


class ListTeamMembersSerializer(OrderMixin): # pylint: disable=abstract-method
"""
Serializer for listing team members.
Expand Down Expand Up @@ -379,3 +403,28 @@ class ListAssignmentsQuerySerializer(ListTeamMemberAssignmentsQuerySerializer):
choices=[(e.value, e.name) for e in UserAssignmentSortField],
default=UserAssignmentSortField.FULL_NAME,
)


class ScopeSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Serializer for scope.
"""

external_key = serializers.SerializerMethodField()
display_name = serializers.SerializerMethodField()
org = serializers.SerializerMethodField()

def get_external_key(self, obj: dict) -> str:
"""Get the external key for the given scope."""
if obj["scope_type"] == ScopesTypeField.LIBRARY:
return str(LibraryLocatorV2(org=obj["org_name"], slug=obj["scope_id"]))
return obj["scope_id"]

def get_display_name(self, obj: dict) -> str:
"""Get the display name for the given scope."""
return str(obj.get("display_name_col") or "")

def get_org(self, obj: dict) -> dict | None:
"""Get the org for the given scope."""
org = self.context.get("org_map", {}).get(obj["org_name"])
return OrganizationSerializer(org).data if org else None
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 @@ -19,4 +19,5 @@
"users/<str:username>/assignments/", views.TeamMemberAssignmentsAPIView.as_view(), name="user-assignment-list"
),
path("assignments/", views.AssignmentsAPIView.as_view(), name="assignment-list"),
path("scopes/", views.ScopesAPIView.as_view(), name="scope-list"),
]
Loading