Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
14 changes: 14 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ Change Log
Unreleased
**********

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

Added
=====

* Add the ``/api/authz/v1/assignments/`` endpoint for listing all user role assignments, to be used in the admin console.

Changed
=======

* Apply view team permissions to the user assignments and team members endpoints.
* Align docstrings and API docs accordingly.

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

ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
15 changes: 14 additions & 1 deletion openedx_authz/api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"ScopeData",
"SubjectData",
"SuperAdminAssignmentData",
"UserAssignmentData",
"UserData",
]

Expand Down Expand Up @@ -1128,11 +1129,22 @@ class SuperAdminAssignmentData:
staff/superuser and their access is not derived from a specific role assignment.
"""

user: "User" = None
user: "User" | None = None
is_staff: bool = False
is_superuser: bool = False


@define
class UserAssignmentData(RoleAssignmentData):
"""Represents a user entry in a team member assignment list.

Used alongside SuperAdminAssignmentData in serializer contexts where individual
assignment information is needed.
"""

user: "User" | None = None


@define
class UserAssignments:
Comment thread
mariajgrimaldi marked this conversation as resolved.
"""A user with their role assignments"""
Expand All @@ -1146,3 +1158,4 @@ class UserAssignmentsFilter(Enum):

SCOPES = "scopes"
ORGS = "orgs"
ROLES = "roles"
6 changes: 6 additions & 0 deletions openedx_authz/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ def _filter_allowed_assignments(
def get_visible_role_assignments_for_user(
orgs: list[str] = None,
scopes: list[str] = None,
roles: list[str] = None,
allowed_for_user_external_key: str = None,
) -> list[UserAssignments]:
"""
Expand Down Expand Up @@ -329,6 +330,11 @@ def get_visible_role_assignments_for_user(
by=UserAssignmentsFilter.ORGS,
values=orgs,
)
users_with_assignments = filter_user_assignments(
users_with_assignments=users_with_assignments,
by=UserAssignmentsFilter.ROLES,
values=roles,
)
return users_with_assignments


Expand Down
2 changes: 2 additions & 0 deletions openedx_authz/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def _get_value_to_filter(assignment: RoleAssignmentData) -> str:
return assignment.scope.external_key
elif by == UserAssignmentsFilter.ORGS:
return getattr(assignment.scope, "org", None)
elif by == UserAssignmentsFilter.ROLES:
return assignment.roles[0].external_key if assignment.roles else None
else:
raise ValueError(f"Invalid filter: '{by}'. Must be one of {[f.value for f in UserAssignmentsFilter]}")

Expand Down
11 changes: 11 additions & 0 deletions openedx_authz/rest_api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ class AssignmentSortField(BaseEnum):
SCOPE = "scope"


class UserAssignmentSortField(BaseEnum):
"""Enum for the user role assignment fields to sort by."""

ROLE = "role"
ORG = "org"
SCOPE = "scope"
NAME = "full_name"
USERNAME = "username"
EMAIL = "email"


class SortOrder(BaseEnum):
"""Enum for the order to sort by."""

Expand Down
76 changes: 65 additions & 11 deletions openedx_authz/rest_api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
GLOBAL_SCOPE_WILDCARD,
ScopeData,
)
from openedx_authz.rest_api.data import AssignmentSortField, SearchField, SortField, SortOrder
from openedx_authz.rest_api.data import (
AssignmentSortField,
BaseEnum,
SearchField,
SortField,
SortOrder,
UserAssignmentSortField,
)


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


def _sort_by_field(
items: list[dict],
sort_by: str,
order: str,
allowed_fields: type[BaseEnum],
) -> list[dict]:
"""
Sort a list of dicts by a given field and order, validating against the provided enum.

Args:
items (list[dict]): The items to sort.
sort_by (str): The field to sort by.
order (str): The order to sort by.
allowed_fields (type[BaseEnum]): The enum class whose values are the valid sort fields.

Raises:
ValueError: If the sort field is invalid.
ValueError: If the sort order is invalid.

Returns:
list[dict]: The sorted items.
"""
if sort_by not in allowed_fields.values():
raise ValueError(f"Invalid field: '{sort_by}'. Must be one of {allowed_fields.values()}")

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

return sorted(
items,
key=lambda item: (item.get(sort_by) or "").lower(),
reverse=order == SortOrder.DESC,
)


def sort_assignments(
assignments: list[dict],
sort_by: AssignmentSortField = AssignmentSortField.ROLE,
Expand All @@ -115,15 +157,27 @@ def sort_assignments(
Returns:
list[dict]: The sorted assignments.
"""
if sort_by not in AssignmentSortField.values():
raise ValueError(f"Invalid field: '{sort_by}'. Must be one of {AssignmentSortField.values()}")
return _sort_by_field(assignments, sort_by, order, AssignmentSortField)

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

sorted_assignments = sorted(
assignments,
key=lambda assignment: (assignment.get(sort_by) or "").lower(),
reverse=order == SortOrder.DESC,
)
return sorted_assignments
def sort_user_assignments(
assignments: list[dict],
sort_by: UserAssignmentSortField = UserAssignmentSortField.ROLE,
order: SortOrder = SortOrder.ASC,
) -> list[dict]:
"""
Sort role assignments by a given field and order.

Args:
assignments (list[dict]): The assignments to sort.
sort_by (UserAssignmentSortField, optional): The field to sort by. Defaults to UserAssignmentSortField.ROLE.
order (SortOrder, optional): The order to sort by. Defaults to SortOrder.ASC.

Raises:
ValueError: If the sort field is invalid.
ValueError: If the sort order is invalid.

Returns:
list[dict]: The sorted assignments.
"""
return _sort_by_field(assignments, sort_by, order, UserAssignmentSortField)
28 changes: 26 additions & 2 deletions openedx_authz/rest_api/v1/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

from rest_framework.filters import BaseFilterBackend

from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder
from openedx_authz.rest_api.utils import filter_users, sort_assignments, sort_users
from openedx_authz.rest_api.data import AssignmentSortField, SearchField, SortField, SortOrder, UserAssignmentSortField
from openedx_authz.rest_api.utils import filter_users, sort_assignments, sort_user_assignments, sort_users


class TeamMemberSearchFilter(BaseFilterBackend):
Expand All @@ -30,3 +30,27 @@ def filter_queryset(self, request, queryset, view):
sort_by = request.query_params.get("sort_by", AssignmentSortField.ROLE)
order = request.query_params.get("order", SortOrder.ASC)
return sort_assignments(assignments=queryset, sort_by=sort_by, order=order)


class UserAssignmentsSearchFilter(BaseFilterBackend):
"""Filter user assignments by a search term over full_name, username, and email."""

def filter_queryset(self, request, queryset, view):
search = request.query_params.get("search")
if not search:
return queryset
search = search.lower()
return [
item
for item in queryset
if any(search in (item.get(field) or "").lower() for field in SearchField.values())
]


class UserAssignmentsOrderingFilter(BaseFilterBackend):
"""Sort user assignments by a given field and order."""

def filter_queryset(self, request, queryset, view):
sort_by = request.query_params.get("sort_by", UserAssignmentSortField.ROLE)
Comment thread
rodmgwgu marked this conversation as resolved.
Outdated
order = request.query_params.get("order", SortOrder.ASC)
return sort_user_assignments(assignments=queryset, sort_by=sort_by, order=order)
35 changes: 34 additions & 1 deletion openedx_authz/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from openedx_authz import api
from openedx_authz.api.data import UserAssignments
from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder
from openedx_authz.rest_api.data import AssignmentSortField, 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 @@ -346,3 +346,36 @@ def get_permission_count(self, obj: api.RoleAssignmentData | api.SuperAdminAssig
return None
case api.RoleAssignmentData():
return len(obj.roles[0].permissions) if obj.roles else 0


class TeamMemberUserAssignmentSerializer(TeamMemberAssignmentSerializer): # pylint: disable=abstract-method
"""Serializer for team member assignments with user information."""

full_name = serializers.SerializerMethodField()
username = serializers.SerializerMethodField()
email = serializers.SerializerMethodField()

def get_full_name(self, obj: api.UserAssignmentData | api.SuperAdminAssignmentData) -> str:
"""Get user full name."""
return obj.user.get_full_name() if obj.user else ""

def get_username(self, obj: api.UserAssignmentData | api.SuperAdminAssignmentData) -> str:
"""Get username."""
return obj.user.username if obj.user else ""

def get_email(self, obj: api.UserAssignmentData | api.SuperAdminAssignmentData) -> str:
"""Get user email."""
return obj.user.email if obj.user else ""


class ListAssignmentsQuerySerializer(ListTeamMemberAssignmentsQuerySerializer): # pylint: disable=abstract-method
"""Serializer for query params for the list all team member assignments endpoint."""

search = LowercaseCharField(required=False, default=None)
scopes = CaseSensitiveCommaSeparatedListField(required=False, default=[])
# Overriding sort_by from OrderMixin due to different choices and default value
sort_by = serializers.ChoiceField(
required=False,
choices=[(e.value, e.name) for e in UserAssignmentSortField],
default=UserAssignmentSortField.NAME,
)
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 @@ -18,4 +18,5 @@
path(
"users/<str:username>/assignments/", views.TeamMemberAssignmentsAPIView.as_view(), name="user-assignment-list"
),
path("assignments/", views.AssignmentsAPIView.as_view(), name="assignment-list"),
]
Loading