Skip to content

Commit f91dc12

Browse files
committed
feat: Implement team members endpoint for admin console
1 parent 0db0991 commit f91dc12

4 files changed

Lines changed: 142 additions & 51 deletions

File tree

openedx_authz/rest_api/utils.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
11
"""Utility functions for the Open edX AuthZ REST API."""
22

3+
from dataclasses import dataclass
4+
import logging
5+
36
from django.contrib.auth import get_user_model
47
from django.db.models import Q
58

6-
from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, ScopeData
9+
from openedx_authz.api.data import (
10+
GLOBAL_SCOPE_WILDCARD,
11+
ContentLibraryData,
12+
CourseOverviewData,
13+
OrgContentLibraryGlobData,
14+
OrgCourseOverviewGlobData,
15+
RoleAssignmentData,
16+
ScopeData,
17+
)
18+
from openedx_authz.api.users import is_user_allowed
19+
from openedx_authz.constants.permissions import COURSES_VIEW_COURSE, VIEW_LIBRARY
720
from openedx_authz.rest_api.data import SearchField, SortField, SortOrder
821

22+
logger = logging.getLogger(__name__)
23+
924
User = get_user_model()
1025

1126

@@ -135,3 +150,51 @@ def filter_users(users: list[dict], search: str | None, roles: list[str] | None)
135150
filtered_users.append(user)
136151

137152
return filtered_users
153+
154+
155+
@dataclass
156+
class UserAssignments:
157+
user: "User"
158+
assignments: list[RoleAssignmentData]
159+
160+
161+
def get_user_assignment_map(role_assignments: list[RoleAssignmentData]) -> list[UserAssignments]:
162+
"""
163+
Group role assignments by user
164+
"""
165+
usernames = {assignment.subject.username for assignment in role_assignments}
166+
user_map = get_user_map(usernames)
167+
168+
users_with_assignments: list[UserAssignments] = []
169+
170+
for username, user in user_map.items():
171+
assignments = [a for a in role_assignments if a.subject.username == username]
172+
users_with_assignments.append(UserAssignments(user=user, assignments=assignments))
173+
174+
return users_with_assignments
175+
176+
177+
def filter_allowed_assignments(
178+
user: "User", assignments: list[RoleAssignmentData]
179+
) -> list[RoleAssignmentData]:
180+
"""
181+
Filter the given role assignments to only include those that the user has permission to view.
182+
"""
183+
allowed_assignments: list[RoleAssignmentData] = []
184+
for assignment in assignments:
185+
permission = None
186+
187+
# For CourseOverviewData and ContentLibraryData, check for the view permission
188+
if isinstance(assignment.scope, (CourseOverviewData, OrgCourseOverviewGlobData)):
189+
permission = COURSES_VIEW_COURSE.identifier
190+
elif isinstance(assignment.scope, (ContentLibraryData, OrgContentLibraryGlobData)):
191+
permission = VIEW_LIBRARY.identifier
192+
193+
if permission and is_user_allowed(
194+
user_external_key=user.username,
195+
action_external_key=permission,
196+
scope_external_key=assignment.scope.external_key
197+
):
198+
allowed_assignments.append(assignment)
199+
200+
return allowed_assignments

openedx_authz/rest_api/v1/fields.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ def to_internal_value(self, data):
1313
def to_representation(self, value):
1414
"""Convert list to string separated by commas"""
1515
return ",".join(value).lower()
16+
17+
class CaseSensitiveCommaSeparatedListField(serializers.CharField):
18+
"""Serializer for a comma-separated list of strings, case-sensitive."""
19+
20+
def to_internal_value(self, data):
21+
"""Convert string separated by commas to list of unique items preserving order"""
22+
return list(dict.fromkeys(item.strip() for item in data.split(",") if item.strip()))
23+
24+
def to_representation(self, value):
25+
"""Convert list to string separated by commas"""
26+
return ",".join(value)
1627

1728

1829
class LowercaseCharField(serializers.CharField):

openedx_authz/rest_api/v1/serializers.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
from openedx_authz import api
77
from openedx_authz.rest_api.data import SortField, SortOrder
8-
from openedx_authz.rest_api.utils import get_generic_scope
9-
from openedx_authz.rest_api.v1.fields import CommaSeparatedListField, LowercaseCharField
8+
from openedx_authz.rest_api.utils import UserAssignments, get_generic_scope
9+
from openedx_authz.rest_api.v1.fields import CaseSensitiveCommaSeparatedListField, CommaSeparatedListField, LowercaseCharField
1010

1111
User = get_user_model()
1212

@@ -207,8 +207,8 @@ def get_roles(self, obj: api.RoleAssignmentData) -> list[str]:
207207
class ListTeamMembersSerializer(serializers.Serializer): # pylint: disable=abstract-method
208208
"""Serializer for listing team members."""
209209

210-
scopes = CommaSeparatedListField(required=False, default=[])
211-
orgs = CommaSeparatedListField(required=False, default=[])
210+
scopes = CaseSensitiveCommaSeparatedListField(required=False, default=[])
211+
orgs = CaseSensitiveCommaSeparatedListField(required=False, default=[])
212212
sort_by = serializers.ChoiceField(
213213
required=False,
214214
choices=[(e.value, e.name) for e in SortField],
@@ -220,3 +220,27 @@ class ListTeamMembersSerializer(serializers.Serializer): # pylint: disable=abst
220220
default=SortOrder.ASC,
221221
)
222222
search = LowercaseCharField(required=False, default=None)
223+
224+
class TeamMemberSerializer(serializers.Serializer): # pylint: disable=abstract-method
225+
"""Serializer for team members."""
226+
227+
username = serializers.SerializerMethodField()
228+
full_name = serializers.SerializerMethodField()
229+
email = serializers.SerializerMethodField()
230+
assignation_count = serializers.SerializerMethodField()
231+
232+
def get_username(self, obj: UserAssignments) -> str:
233+
"""Get the username for the given role assignment."""
234+
return getattr(obj.user, "username", "") if obj.user else ""
235+
236+
def get_full_name(self, obj: UserAssignments) -> str:
237+
"""Get the full name for the given role assignment."""
238+
return obj.user.get_full_name() if obj.user else ""
239+
240+
def get_email(self, obj: UserAssignments) -> str:
241+
"""Get the email for the given role assignment."""
242+
return getattr(obj.user, "email", "") if obj.user else ""
243+
244+
def get_assignation_count(self, obj: UserAssignments) -> int:
245+
"""Get the assignation count for the given role assignment."""
246+
return len(obj.assignments)

openedx_authz/rest_api/v1/views.py

Lines changed: 39 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
permissions, roles, and user assignments within Open edX platform.
66
"""
77

8-
from dataclasses import dataclass
98
import logging
109

1110
import edx_api_doc_tools as apidocs
@@ -17,11 +16,14 @@
1716

1817
from openedx_authz import api
1918
from openedx_authz.constants import permissions
20-
from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus
19+
from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus, SortField
2120
from openedx_authz.rest_api.decorators import authz_permissions, view_auth_classes
2221
from openedx_authz.rest_api.utils import (
22+
UserAssignments,
23+
filter_allowed_assignments,
2324
filter_users,
2425
get_generic_scope,
26+
get_user_assignment_map,
2527
get_user_by_username_or_email,
2628
get_user_map,
2729
sort_users,
@@ -37,6 +39,7 @@
3739
PermissionValidationResponseSerializer,
3840
PermissionValidationSerializer,
3941
RemoveUsersFromRoleWithScopeSerializer,
42+
TeamMemberSerializer,
4043
UserRoleAssignmentSerializer,
4144
)
4245

@@ -452,27 +455,6 @@ def get(self, request: HttpRequest) -> Response:
452455
serialized_data = ListRolesWithScopeResponseSerializer(paginated_response_data, many=True)
453456
return paginator.get_paginated_response(serialized_data.data)
454457

455-
@dataclass
456-
class UserAssignments:
457-
user: "User"
458-
assignments: list[api.RoleAssignmentData]
459-
460-
def get_user_assignment_map(role_assignments: list[api.RoleAssignmentData]) -> list[UserAssignments]:
461-
"""
462-
Group role assignments by user
463-
"""
464-
usernames = {assignment.subject.username for assignment in role_assignments}
465-
user_map = get_user_map(usernames)
466-
467-
users_with_assignments: list[UserAssignments] = []
468-
469-
for username, user in user_map.items():
470-
assignments = [a for a in role_assignments if a.subject.username == username]
471-
users_with_assignments.append(UserAssignments(user=user, assignments=assignments))
472-
473-
return users_with_assignments
474-
475-
476458

477459
@view_auth_classes()
478460
class TeamMembersAPIView(APIView):
@@ -507,31 +489,42 @@ def get(self, request: HttpRequest) -> Response:
507489
query_params = serializer.validated_data
508490

509491
user_role_assignments = api.get_all_user_role_assignments()
492+
user_role_assignments = filter_allowed_assignments(user=request.user, assignments=user_role_assignments)
510493
# Group assignments by user
511494
users_with_assignments = get_user_assignment_map(user_role_assignments)
512495

513-
if query_params.scopes:
514-
# Filter by scopes
515-
scopes = {s.strip() for s in query_params.scopes.split(",")}
516-
users_with_assignments = [
517-
uwa for uwa in users_with_assignments if any(a.scope.external_key in scopes for a in uwa.assignments)
518-
]
496+
scopes = query_params.get('scopes')
497+
orgs = query_params.get('orgs')
498+
search = query_params.get('search')
499+
order = query_params.get('order')
500+
sort_by = query_params.get('sort_by', SortField.USERNAME)
519501

520-
if query_params.orgs:
502+
if scopes:
503+
# Filter by scopes
504+
filtered_users: list[UserAssignments] = []
505+
for uwa in users_with_assignments:
506+
if any(a.scope.external_key in scopes for a in uwa.assignments):
507+
# Also filter assignments to reflect the correct number of assignments
508+
filtered_assignments = [a for a in uwa.assignments if a.scope.external_key in scopes]
509+
filtered_users.append(UserAssignments(user=uwa.user, assignments=filtered_assignments))
510+
users_with_assignments = filtered_users
511+
512+
if orgs:
521513
# Filter by orgs
522-
orgs = {o.strip() for o in query_params.orgs.split(",")}
523-
users_with_assignments = [
524-
uwa for uwa in users_with_assignments if any(a.scope.org in orgs for a in uwa.assignments)
525-
]
526-
527-
528-
logger.info(f">>>>Query params: {query_params}")
529-
logger.info(f">>>>assignments: {user_role_assignments}")
530-
531-
# sort
532-
# paginate
533-
534-
535-
return Response({"results": []}, status=status.HTTP_200_OK)
536-
537-
514+
filtered_users: list[UserAssignments] = []
515+
for uwa in users_with_assignments:
516+
if any(a.scope.org in orgs for a in uwa.assignments):
517+
# Also filter assignments to reflect the correct number of assignments
518+
filtered_assignments = [a for a in uwa.assignments if a.scope.org in orgs]
519+
filtered_users.append(UserAssignments(user=uwa.user, assignments=filtered_assignments))
520+
users_with_assignments = filtered_users
521+
522+
team_members = TeamMemberSerializer(users_with_assignments, many=True)
523+
# Search
524+
filtered_team_members = filter_users(users=team_members.data, search=search, roles=None)
525+
# Sort
526+
sorted_team_members = sort_users(users=filtered_team_members, sort_by=sort_by, order=order)
527+
# Paginate
528+
paginator = self.pagination_class()
529+
paginated_response_data = paginator.paginate_queryset(sorted_team_members, request)
530+
return paginator.get_paginated_response(paginated_response_data)

0 commit comments

Comments
 (0)