Skip to content

Commit a936a66

Browse files
authored
feat: Implement all assignments endpoint (#262)
1 parent 14070da commit a936a66

14 files changed

Lines changed: 1340 additions & 76 deletions

File tree

CHANGELOG.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
1.9.0 - 2026-04-14
18+
******************
19+
20+
Added
21+
=====
22+
23+
* Add the ``/api/authz/v1/assignments/`` endpoint for listing all user role assignments, to be used in the admin console.
24+
25+
Changed
26+
=======
27+
28+
* Apply view team permissions to the user assignments and team members endpoints.
29+
* Align docstrings and API docs accordingly.
30+
1731
1.8.0 - 2026-04-14
1832
******************
1933

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.8.0"
7+
__version__ = "1.9.0"
88

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

openedx_authz/api/data.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"ScopeData",
3939
"SubjectData",
4040
"SuperAdminAssignmentData",
41+
"UserAssignmentData",
4142
"UserData",
4243
]
4344

@@ -1128,14 +1129,30 @@ class SuperAdminAssignmentData:
11281129
staff/superuser and their access is not derived from a specific role assignment.
11291130
"""
11301131

1131-
user: "User" = None
1132+
user: "User" | None = None
11321133
is_staff: bool = False
11331134
is_superuser: bool = False
11341135

11351136

1137+
@define
1138+
class UserAssignmentData(RoleAssignmentData):
1139+
"""Represents a user entry in a team member assignment list.
1140+
1141+
Used alongside SuperAdminAssignmentData in serializer contexts where individual
1142+
assignment along with its assigned user information is needed.
1143+
"""
1144+
1145+
user: "User" | None = None
1146+
1147+
11361148
@define
11371149
class UserAssignments:
1138-
"""A user with their role assignments"""
1150+
"""A user with their role assignments
1151+
1152+
Used in serializer context where a user is grouped with their assignments.
1153+
1154+
This is different to UserAssignmentData because here we are grouping multiple assignments to an individual user.
1155+
"""
11391156

11401157
user: "User"
11411158
assignments: list[RoleAssignmentData]
@@ -1146,3 +1163,4 @@ class UserAssignmentsFilter(Enum):
11461163

11471164
SCOPES = "scopes"
11481165
ORGS = "orgs"
1166+
ROLES = "roles"

openedx_authz/api/users.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ def _filter_allowed_assignments(
296296
def get_visible_role_assignments_for_user(
297297
orgs: list[str] = None,
298298
scopes: list[str] = None,
299+
roles: list[str] = None,
299300
allowed_for_user_external_key: str = None,
300301
) -> list[UserAssignments]:
301302
"""
@@ -329,6 +330,11 @@ def get_visible_role_assignments_for_user(
329330
by=UserAssignmentsFilter.ORGS,
330331
values=orgs,
331332
)
333+
users_with_assignments = filter_user_assignments(
334+
users_with_assignments=users_with_assignments,
335+
by=UserAssignmentsFilter.ROLES,
336+
values=roles,
337+
)
332338
return users_with_assignments
333339

334340

openedx_authz/api/utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def get_user_map(usernames: list[str]) -> dict[str, User]:
2727
dict[str, User]: Dictionary mapping each username to its corresponding User object.
2828
Only users that exist in the database are included in the returned dictionary.
2929
"""
30-
users = User.objects.filter(username__in=usernames).select_related("profile")
30+
users = User.objects.filter(username__in=usernames, is_active=True).select_related("profile")
3131
return {user.username: user for user in users}
3232

3333

@@ -74,6 +74,8 @@ def _get_value_to_filter(assignment: RoleAssignmentData) -> str:
7474
return assignment.scope.external_key
7575
elif by == UserAssignmentsFilter.ORGS:
7676
return getattr(assignment.scope, "org", None)
77+
elif by == UserAssignmentsFilter.ROLES:
78+
return assignment.roles[0].external_key if assignment.roles else None
7779
else:
7880
raise ValueError(f"Invalid filter: '{by}'. Must be one of {[f.value for f in UserAssignmentsFilter]}")
7981

openedx_authz/rest_api/data.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ class AssignmentSortField(BaseEnum):
2828
SCOPE = "scope"
2929

3030

31+
class UserAssignmentSortField(BaseEnum):
32+
"""Enum for the user role assignment fields to sort by."""
33+
34+
ROLE = "role"
35+
ORG = "org"
36+
SCOPE = "scope"
37+
FULL_NAME = "full_name"
38+
USERNAME = "username"
39+
EMAIL = "email"
40+
41+
3142
class SortOrder(BaseEnum):
3243
"""Enum for the order to sort by."""
3344

openedx_authz/rest_api/utils.py

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
GLOBAL_SCOPE_WILDCARD,
55
ScopeData,
66
)
7-
from openedx_authz.rest_api.data import AssignmentSortField, SearchField, SortField, SortOrder
7+
from openedx_authz.rest_api.data import (
8+
AssignmentSortField,
9+
BaseEnum,
10+
SearchField,
11+
SortField,
12+
SortOrder,
13+
UserAssignmentSortField,
14+
)
815

916

1017
def get_generic_scope(scope: ScopeData) -> ScopeData:
@@ -95,6 +102,41 @@ def filter_users(users: list[dict], search: str | None, roles: list[str] | None)
95102
return filtered_users
96103

97104

105+
def _sort_by_field(
106+
items: list[dict],
107+
sort_by: str,
108+
order: str,
109+
allowed_fields: type[BaseEnum],
110+
) -> list[dict]:
111+
"""
112+
Sort a list of dicts by a given field and order, validating against the provided enum.
113+
114+
Args:
115+
items (list[dict]): The items to sort.
116+
sort_by (str): The field to sort by.
117+
order (str): The order to sort by.
118+
allowed_fields (type[BaseEnum]): The enum class whose values are the valid sort fields.
119+
120+
Raises:
121+
ValueError: If the sort field is invalid.
122+
ValueError: If the sort order is invalid.
123+
124+
Returns:
125+
list[dict]: The sorted items.
126+
"""
127+
if sort_by not in allowed_fields.values():
128+
raise ValueError(f"Invalid field: '{sort_by}'. Must be one of {allowed_fields.values()}")
129+
130+
if order not in SortOrder.values():
131+
raise ValueError(f"Invalid order: '{order}'. Must be one of {SortOrder.values()}")
132+
133+
return sorted(
134+
items,
135+
key=lambda item: (item.get(sort_by) or "").lower(),
136+
reverse=order == SortOrder.DESC,
137+
)
138+
139+
98140
def sort_assignments(
99141
assignments: list[dict],
100142
sort_by: AssignmentSortField = AssignmentSortField.ROLE,
@@ -115,15 +157,27 @@ def sort_assignments(
115157
Returns:
116158
list[dict]: The sorted assignments.
117159
"""
118-
if sort_by not in AssignmentSortField.values():
119-
raise ValueError(f"Invalid field: '{sort_by}'. Must be one of {AssignmentSortField.values()}")
160+
return _sort_by_field(assignments, sort_by, order, AssignmentSortField)
120161

121-
if order not in SortOrder.values():
122-
raise ValueError(f"Invalid order: '{order}'. Must be one of {SortOrder.values()}")
123162

124-
sorted_assignments = sorted(
125-
assignments,
126-
key=lambda assignment: (assignment.get(sort_by) or "").lower(),
127-
reverse=order == SortOrder.DESC,
128-
)
129-
return sorted_assignments
163+
def sort_user_assignments(
164+
assignments: list[dict],
165+
sort_by: UserAssignmentSortField = UserAssignmentSortField.ROLE,
166+
order: SortOrder = SortOrder.ASC,
167+
) -> list[dict]:
168+
"""
169+
Sort role assignments by a given field and order.
170+
171+
Args:
172+
assignments (list[dict]): The assignments to sort.
173+
sort_by (UserAssignmentSortField, optional): The field to sort by. Defaults to UserAssignmentSortField.ROLE.
174+
order (SortOrder, optional): The order to sort by. Defaults to SortOrder.ASC.
175+
176+
Raises:
177+
ValueError: If the sort field is invalid.
178+
ValueError: If the sort order is invalid.
179+
180+
Returns:
181+
list[dict]: The sorted assignments.
182+
"""
183+
return _sort_by_field(assignments, sort_by, order, UserAssignmentSortField)

openedx_authz/rest_api/v1/filters.py

Lines changed: 26 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 AssignmentSortField, SortField, SortOrder
6-
from openedx_authz.rest_api.utils import filter_users, sort_assignments, sort_users
5+
from openedx_authz.rest_api.data import AssignmentSortField, SearchField, SortField, SortOrder, UserAssignmentSortField
6+
from openedx_authz.rest_api.utils import filter_users, sort_assignments, sort_user_assignments, sort_users
77

88

99
class TeamMemberSearchFilter(BaseFilterBackend):
@@ -30,3 +30,27 @@ def filter_queryset(self, request, queryset, view):
3030
sort_by = request.query_params.get("sort_by", AssignmentSortField.ROLE)
3131
order = request.query_params.get("order", SortOrder.ASC)
3232
return sort_assignments(assignments=queryset, sort_by=sort_by, order=order)
33+
34+
35+
class UserAssignmentsSearchFilter(BaseFilterBackend):
36+
"""Filter user assignments by a search term over full_name, username, and email."""
37+
38+
def filter_queryset(self, request, queryset, view):
39+
search = request.query_params.get("search")
40+
if not search:
41+
return queryset
42+
search = search.lower()
43+
return [
44+
item
45+
for item in queryset
46+
if any(search in (item.get(field) or "").lower() for field in SearchField.values())
47+
]
48+
49+
50+
class UserAssignmentsOrderingFilter(BaseFilterBackend):
51+
"""Sort user assignments by a given field and order."""
52+
53+
def filter_queryset(self, request, queryset, view):
54+
sort_by = request.query_params.get("sort_by", UserAssignmentSortField.FULL_NAME)
55+
order = request.query_params.get("order", SortOrder.ASC)
56+
return sort_user_assignments(assignments=queryset, sort_by=sort_by, order=order)

openedx_authz/rest_api/v1/serializers.py

Lines changed: 34 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 AssignmentSortField, SortField, SortOrder
8+
from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder, UserAssignmentSortField
99
from openedx_authz.rest_api.utils import get_generic_scope
1010
from openedx_authz.rest_api.v1.fields import (
1111
CaseSensitiveCommaSeparatedListField,
@@ -346,3 +346,36 @@ def get_permission_count(self, obj: api.RoleAssignmentData | api.SuperAdminAssig
346346
return None
347347
case api.RoleAssignmentData():
348348
return len(obj.roles[0].permissions) if obj.roles else 0
349+
350+
351+
class TeamMemberUserAssignmentSerializer(TeamMemberAssignmentSerializer): # pylint: disable=abstract-method
352+
"""Serializer for team member assignments with user information."""
353+
354+
full_name = serializers.SerializerMethodField()
355+
username = serializers.SerializerMethodField()
356+
email = serializers.SerializerMethodField()
357+
358+
def get_full_name(self, obj: api.UserAssignmentData | api.SuperAdminAssignmentData) -> str:
359+
"""Get user full name."""
360+
return obj.user.get_full_name() if obj.user else ""
361+
362+
def get_username(self, obj: api.UserAssignmentData | api.SuperAdminAssignmentData) -> str:
363+
"""Get username."""
364+
return obj.user.username if obj.user else ""
365+
366+
def get_email(self, obj: api.UserAssignmentData | api.SuperAdminAssignmentData) -> str:
367+
"""Get user email."""
368+
return obj.user.email if obj.user else ""
369+
370+
371+
class ListAssignmentsQuerySerializer(ListTeamMemberAssignmentsQuerySerializer): # pylint: disable=abstract-method
372+
"""Serializer for query params for the list all team member assignments endpoint."""
373+
374+
search = LowercaseCharField(required=False, default=None)
375+
scopes = CaseSensitiveCommaSeparatedListField(required=False, default=[])
376+
# Overriding sort_by from OrderMixin due to different choices and default value
377+
sort_by = serializers.ChoiceField(
378+
required=False,
379+
choices=[(e.value, e.name) for e in UserAssignmentSortField],
380+
default=UserAssignmentSortField.FULL_NAME,
381+
)

openedx_authz/rest_api/v1/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@
1818
path(
1919
"users/<str:username>/assignments/", views.TeamMemberAssignmentsAPIView.as_view(), name="user-assignment-list"
2020
),
21+
path("assignments/", views.AssignmentsAPIView.as_view(), name="assignment-list"),
2122
]

0 commit comments

Comments
 (0)