Skip to content

Commit 832a7af

Browse files
committed
feat: Implemented team member assignments endpoint
feat: Implemented team member assignments endpoint
1 parent e33e57b commit 832a7af

10 files changed

Lines changed: 483 additions & 10 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.6.0 - 2026-04-09
18+
******************
19+
20+
Added
21+
=====
22+
23+
* Add the ``/api/authz/v1/users/<username>/assignments`` endpoint to get a list of role assignations for a user.
24+
1725
1.5.0 - 2026-04-09
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.5.0"
7+
__version__ = "1.6.0"
88

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

openedx_authz/api/users.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"get_user_role_assignments",
4545
"get_user_role_assignments_in_scope",
4646
"get_user_role_assignments_for_role_in_scope",
47+
"get_user_role_assignments_for_user_filtered",
4748
"get_user_role_assignments_filtered",
4849
"get_all_user_role_assignments_in_scope",
4950
"get_visible_role_assignments_for_user",
@@ -168,6 +169,42 @@ def get_user_role_assignments_for_role_in_scope(
168169
)
169170

170171

172+
def get_user_role_assignments_for_user_filtered(
173+
user_external_key: str,
174+
orgs: list[str] = None,
175+
roles: list[str] = None,
176+
allowed_for_user_external_key: str = None,
177+
) -> list[RoleAssignmentData]:
178+
"""
179+
Get role assignments for a specific user, filtered by orgs and/or roles,
180+
and only include assignments that the specified user has permission to view.
181+
182+
Args:
183+
user_external_key: The user to get assignments for (e.g., 'john_doe').
184+
orgs: Optional list of orgs to filter by (e.g., ['edX', 'MITx']).
185+
roles: Optional list of roles to filter by (e.g., ['library_admin']).
186+
allowed_for_user_external_key: The username to check permissions against (e.g., 'john_doe').
187+
188+
Returns:
189+
list[RoleAssignmentData]: A list of role assignments for the user, filtered by orgs/roles and permissions.
190+
"""
191+
user_role_assignments = get_user_role_assignments(user_external_key=user_external_key)
192+
# Filter assignments based on the user's permissions
193+
user_role_assignments = _filter_allowed_assignments(
194+
user_external_key=allowed_for_user_external_key,
195+
assignments=user_role_assignments,
196+
)
197+
if orgs:
198+
# Filter by orgs
199+
user_role_assignments = [a for a in user_role_assignments if a.scope.org in orgs]
200+
if roles:
201+
# Filter by roles
202+
user_role_assignments = [
203+
a for a in user_role_assignments if any(role.external_key in roles for role in a.roles)
204+
]
205+
return user_role_assignments
206+
207+
171208
def get_user_role_assignments_filtered(
172209
*,
173210
user_external_key: str | None = None,

openedx_authz/rest_api/data.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ class SortField(BaseEnum):
2020
EMAIL = "email"
2121

2222

23+
class AssignmentSortField(BaseEnum):
24+
"""Enum for the role assignment fields to sort by."""
25+
26+
ROLE = "role"
27+
ORG = "org"
28+
SCOPE = "scope"
29+
30+
2331
class SortOrder(BaseEnum):
2432
"""Enum for the order to sort by."""
2533

openedx_authz/rest_api/utils.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
from openedx_authz.api.data import (
44
GLOBAL_SCOPE_WILDCARD,
5+
RoleAssignmentData,
56
ScopeData,
67
)
7-
from openedx_authz.rest_api.data import SearchField, SortField, SortOrder
8+
from openedx_authz.rest_api.data import AssignmentSortField, SearchField, SortField, SortOrder
89

910

1011
def get_generic_scope(scope: ScopeData) -> ScopeData:
@@ -93,3 +94,37 @@ def filter_users(users: list[dict], search: str | None, roles: list[str] | None)
9394
filtered_users.append(user)
9495

9596
return filtered_users
97+
98+
99+
def sort_assignments(
100+
assignments: list[RoleAssignmentData],
101+
sort_by: AssignmentSortField = AssignmentSortField.ROLE,
102+
order: SortOrder = SortOrder.ASC,
103+
) -> list[RoleAssignmentData]:
104+
"""
105+
Sort role assignments by a given field and order.
106+
107+
Args:
108+
assignments (list[RoleAssignmentData]): The assignments to sort.
109+
sort_by (SortField, optional): The field to sort by. Defaults to AssignmentSortField.ROLE.
110+
order (SortOrder, optional): The order to sort by. Defaults to SortOrder.ASC.
111+
112+
Raises:
113+
ValueError: If the sort field is invalid.
114+
ValueError: If the sort order is invalid.
115+
116+
Returns:
117+
list[RoleAssignmentData]: The sorted assignments.
118+
"""
119+
if sort_by not in AssignmentSortField.values():
120+
raise ValueError(f"Invalid field: '{sort_by}'. Must be one of {AssignmentSortField.values()}")
121+
122+
if order not in SortOrder.values():
123+
raise ValueError(f"Invalid order: '{order}'. Must be one of {SortOrder.values()}")
124+
125+
sorted_assignments = sorted(
126+
assignments,
127+
key=lambda assignment: (assignment.get(sort_by) or "").lower(),
128+
reverse=order == SortOrder.DESC,
129+
)
130+
return sorted_assignments

openedx_authz/rest_api/v1/filters.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
from rest_framework.filters import BaseFilterBackend
44

5-
from openedx_authz.rest_api.data import SortField, SortOrder
6-
from openedx_authz.rest_api.utils import filter_users, sort_users
5+
from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder
6+
from openedx_authz.rest_api.utils import filter_users, sort_assignments, sort_users
77

88

99
class TeamMemberSearchFilter(BaseFilterBackend):
@@ -21,3 +21,12 @@ def filter_queryset(self, request, queryset, view):
2121
sort_by = request.query_params.get("sort_by", SortField.USERNAME)
2222
order = request.query_params.get("order", SortOrder.ASC)
2323
return sort_users(users=queryset, sort_by=sort_by, order=order)
24+
25+
26+
class TeamMemberAssignmentsOrderingFilter(BaseFilterBackend):
27+
"""Sort team member assignments by a given field and order."""
28+
29+
def filter_queryset(self, request, queryset, view):
30+
sort_by = request.query_params.get("sort_by", AssignmentSortField.ROLE)
31+
order = request.query_params.get("order", SortOrder.ASC)
32+
return sort_assignments(assignments=queryset, sort_by=sort_by, order=order)

openedx_authz/rest_api/v1/serializers.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from openedx_authz import api
77
from openedx_authz.api.data import UserAssignments
8-
from openedx_authz.rest_api.data import SortField, SortOrder
8+
from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder
99
from openedx_authz.rest_api.utils import get_generic_scope
1010
from openedx_authz.rest_api.v1.fields import (
1111
CaseSensitiveCommaSeparatedListField,
@@ -259,3 +259,47 @@ def get_email(self, obj: UserAssignments) -> str:
259259
def get_assignation_count(self, obj: UserAssignments) -> int:
260260
"""Get the assignation count for the given role assignment."""
261261
return len(obj.assignments)
262+
263+
264+
class ListTeamMemberAssignmentsSerializer(serializers.Serializer): # pylint: disable=abstract-method
265+
"""Serializer for listing team member assignments."""
266+
267+
orgs = CaseSensitiveCommaSeparatedListField(required=False, default=[])
268+
roles = CaseSensitiveCommaSeparatedListField(required=False, default=[])
269+
sort_by = serializers.ChoiceField(
270+
required=False,
271+
choices=[(e.value, e.name) for e in AssignmentSortField],
272+
default=AssignmentSortField.ROLE,
273+
)
274+
order = serializers.ChoiceField(
275+
required=False,
276+
choices=[(e.value, e.name) for e in SortOrder],
277+
default=SortOrder.ASC,
278+
)
279+
280+
281+
class TeamMemberAssignmentSerializer(serializers.Serializer): # pylint: disable=abstract-method
282+
"""Serializer for team member assignments."""
283+
284+
role = serializers.SerializerMethodField()
285+
org = serializers.SerializerMethodField()
286+
scope = serializers.SerializerMethodField()
287+
permission_count = serializers.SerializerMethodField()
288+
289+
def get_role(self, obj: api.RoleAssignmentData) -> str:
290+
"""Get the role for the given role assignment."""
291+
return obj.roles[0].external_key if obj.roles else ""
292+
293+
def get_org(self, obj: api.RoleAssignmentData) -> str:
294+
"""Get the org for the given role assignment."""
295+
if isinstance(obj.scope, (api.ContentLibraryData, api.OrgContentLibraryGlobData)):
296+
return obj.scope.org
297+
return ""
298+
299+
def get_scope(self, obj: api.RoleAssignmentData) -> str:
300+
"""Get the scope for the given role assignment."""
301+
return obj.scope.external_key
302+
303+
def get_permission_count(self, obj: api.RoleAssignmentData) -> int:
304+
"""Get the permission count for the given role assignment."""
305+
return len(obj.roles[0].permissions) if obj.roles else 0

openedx_authz/rest_api/v1/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@
1414
path("roles/users/", views.RoleUserAPIView.as_view(), name="role-user-list"),
1515
path("orgs/", views.AdminConsoleOrgsAPIView.as_view(), name="orgs-list"),
1616
path("users/", views.TeamMembersAPIView.as_view(), name="user-list"),
17+
path("users/<str:username>/assignments", views.TeamMemberAssignmentsAPIView.as_view(), name="user-assignment-list"),
1718
]

openedx_authz/rest_api/v1/views.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from rest_framework.views import APIView
2121

2222
from openedx_authz import api
23+
from openedx_authz.api.users import get_user_role_assignments_for_user_filtered
2324
from openedx_authz.api.utils import get_user_map
2425
from openedx_authz.constants import permissions
2526
from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus
@@ -29,18 +30,24 @@
2930
get_generic_scope,
3031
sort_users,
3132
)
32-
from openedx_authz.rest_api.v1.filters import TeamMemberOrderingFilter, TeamMemberSearchFilter
33+
from openedx_authz.rest_api.v1.filters import (
34+
TeamMemberAssignmentsOrderingFilter,
35+
TeamMemberOrderingFilter,
36+
TeamMemberSearchFilter,
37+
)
3338
from openedx_authz.rest_api.v1.paginators import AuthZAPIViewPagination
3439
from openedx_authz.rest_api.v1.permissions import AnyScopePermission, DynamicScopePermission
3540
from openedx_authz.rest_api.v1.serializers import (
3641
AddUsersToRoleWithScopeSerializer,
3742
ListRolesWithScopeResponseSerializer,
3843
ListRolesWithScopeSerializer,
44+
ListTeamMemberAssignmentsSerializer,
3945
ListTeamMembersSerializer,
4046
ListUsersInRoleWithScopeSerializer,
4147
PermissionValidationResponseSerializer,
4248
PermissionValidationSerializer,
4349
RemoveUsersFromRoleWithScopeSerializer,
50+
TeamMemberAssignmentSerializer,
4451
TeamMemberSerializer,
4552
UserRoleAssignmentSerializer,
4653
)
@@ -590,3 +597,50 @@ def get(self, request: HttpRequest) -> Response:
590597
paginator = self.pagination_class()
591598
paginated_response_data = paginator.paginate_queryset(team_members, request)
592599
return paginator.get_paginated_response(paginated_response_data)
600+
601+
602+
@view_auth_classes()
603+
class TeamMemberAssignmentsAPIView(APIView):
604+
"""
605+
API view for listing user role assignments
606+
"""
607+
608+
pagination_class = AuthZAPIViewPagination
609+
filter_backends = [TeamMemberAssignmentsOrderingFilter]
610+
611+
@apidocs.schema(
612+
parameters=[
613+
apidocs.query_parameter("orgs", str, description="The orgs to query assignations for"),
614+
apidocs.query_parameter("roles", str, description="The roles to query assignations for"),
615+
apidocs.query_parameter("sort_by", str, description="The field to sort by"),
616+
apidocs.query_parameter("order", str, description="The order to sort by"),
617+
apidocs.query_parameter("page", int, description="Page number for pagination"),
618+
apidocs.query_parameter("page_size", int, description="Number of items per page"),
619+
],
620+
responses={
621+
status.HTTP_200_OK: TeamMemberAssignmentSerializer(many=True),
622+
status.HTTP_400_BAD_REQUEST: "The request parameters are invalid",
623+
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions",
624+
},
625+
)
626+
def get(self, request: HttpRequest, username: str) -> Response:
627+
"""Retrieve all user role assignments."""
628+
serializer = ListTeamMemberAssignmentsSerializer(data=request.query_params)
629+
serializer.is_valid(raise_exception=True)
630+
query_params = serializer.validated_data
631+
632+
user_role_assignments = get_user_role_assignments_for_user_filtered(
633+
user_external_key=username,
634+
orgs=query_params.get("orgs"),
635+
roles=query_params.get("roles"),
636+
allowed_for_user_external_key=request.user.username,
637+
)
638+
639+
assignments = TeamMemberAssignmentSerializer(user_role_assignments, many=True).data
640+
for backend in self.filter_backends:
641+
assignments = backend().filter_queryset(request, assignments, self)
642+
643+
# Paginate
644+
paginator = self.pagination_class()
645+
paginated_response_data = paginator.paginate_queryset(assignments, request)
646+
return paginator.get_paginated_response(paginated_response_data)

0 commit comments

Comments
 (0)