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

1.24.0 - 2026-09-14
*******************

Changed
=======

* Add assignments array to the response of GET /api/authz/v1/users/ endpoint.
* Add ``assignments_limit`` query parameter (default 3, max 10) to control the number of inline assignments per user.
* Rename ``assignation_count`` to ``assignment_count`` for consistency with the rest of the codebase.
* Add roles query parameter passthrough to the underlying API call.
* Add ``get_scope_display_name_map`` batch helper to ``api/utils.py``.
* Retrieve ``full_name`` from ``UserProfile.name`` instead of ``get_full_name()`` for consistency across serializers.

1.23.0 - 2026-08-13
*******************

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.23.0"
__version__ = "1.24.0"

ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
77 changes: 77 additions & 0 deletions openedx_authz/api/utils.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
"""Utility functions used on api"""

import logging
import operator
from functools import reduce

from django.contrib.auth import get_user_model
from django.db.models import Q

from openedx_authz.api.data import (
ContentLibraryData,
CourseOverviewData,
RoleAssignmentData,
ScopeData,
UserAssignments,
)
from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model

log = logging.getLogger(__name__)

User = get_user_model()
ContentLibrary = get_content_library_model()
CourseOverview = get_course_overview_model()


def get_user_map(usernames: list[str]) -> dict[str, User]:
Expand Down Expand Up @@ -44,3 +57,67 @@ def get_user_assignment_map(role_assignments: list[RoleAssignmentData]) -> list[
users_with_assignments.append(UserAssignments(user=user, assignments=assignments))

return users_with_assignments


def get_scope_display_name_map(scope_external_keys: set[str]) -> dict[str, str]:
"""Build a mapping of scope external keys to their display names.

Accepts a set of scope external key strings, partitions them into library
and course scopes via :class:`ScopeData`, batch-queries the ContentLibrary
and CourseOverview models, and returns a single dict that maps each key to
its human-readable display name.

Glob scopes (org-level and platform-level wildcards) are skipped because
they don't correspond to a single DB record.

Args:
scope_external_keys: The scope external key strings to resolve.

Returns:
A dict mapping scope external_key strings to display name strings.
Scopes that could not be resolved (e.g. glob patterns, missing DB
records, or unregistered keys) are omitted from the result.
"""
display_name_map: dict[str, str] = {}

# Partition concrete (non-glob) scopes by type.
library_scopes: list[ContentLibraryData] = []
course_scope_keys: set[str] = set()

for key in scope_external_keys:
try:
scope = ScopeData(external_key=key)
except ValueError:
continue
if scope.IS_GLOB:
continue
if isinstance(scope, ContentLibraryData):
library_scopes.append(scope)
elif isinstance(scope, CourseOverviewData):
course_scope_keys.add(scope.external_key)

# Batch-query ContentLibrary display names.
if library_scopes and ContentLibrary is not None:
lib_pairs = {(s.library_key.org, s.library_key.slug) for s in library_scopes}
try:
lib_filter = reduce(
operator.or_, (Q(org__short_name=org, slug=slug) for org, slug in lib_pairs)
)
lib_qs = ContentLibrary.objects.filter(lib_filter).select_related("learning_package", "org")

for lib in lib_qs:
external_key = f"lib:{lib.org.short_name}:{lib.slug}"
display_name_map[external_key] = getattr(lib.learning_package, "title", "") or ""
except Exception: # pylint: disable=broad-exception-caught
log.exception("Failed to fetch ContentLibrary display names")

# Batch-query CourseOverview display names.
if course_scope_keys and CourseOverview is not None:
try:
course_qs = CourseOverview.objects.filter(id__in=course_scope_keys)
for course in course_qs:
display_name_map[str(course.id)] = course.display_name or ""
except Exception: # pylint: disable=broad-exception-caught
log.exception("Failed to fetch CourseOverview display names")

return display_name_map
63 changes: 58 additions & 5 deletions openedx_authz/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,18 @@ class ListTeamMembersSerializer(OrderMixin): # pylint: disable=abstract-method
In this content, a team member is anyone with studio access.
"""

ASSIGNMENTS_LIMIT_DEFAULT = 3
ASSIGNMENTS_LIMIT_MAX = 10

roles = CommaSeparatedListField(required=False, default=[])
scopes = CaseSensitiveCommaSeparatedListField(required=False, default=[])
orgs = CaseSensitiveCommaSeparatedListField(required=False, default=[])
search = LowercaseCharField(required=False, default=None)
assignments_limit = serializers.IntegerField(required=False, default=ASSIGNMENTS_LIMIT_DEFAULT, min_value=1)

def validate_assignments_limit(self, value: int) -> int:
"""Cap assignments_limit to the maximum allowed value."""
return min(value, self.ASSIGNMENTS_LIMIT_MAX)


class TeamMemberSerializer(serializers.Serializer): # pylint: disable=abstract-method
Expand All @@ -321,24 +330,41 @@ class TeamMemberSerializer(serializers.Serializer): # pylint: disable=abstract-
username = serializers.SerializerMethodField()
full_name = serializers.SerializerMethodField()
Comment thread
BryanttV marked this conversation as resolved.
email = serializers.SerializerMethodField()
assignation_count = serializers.SerializerMethodField()
assignment_count = serializers.SerializerMethodField()
assignments = serializers.SerializerMethodField()

def _get_user(self, obj: UserAssignments) -> User | None:
"""Get the user object from the pre-fetched user map in context."""
user_map = self.context.get("user_map", {})
username = getattr(obj.user, "username", None)
return user_map.get(username) if username else None

def get_username(self, obj: UserAssignments) -> str:
"""Get the username for the given role assignment."""
return getattr(obj.user, "username", "") if obj.user else ""

def get_full_name(self, obj: UserAssignments) -> str:
"""Get the full name for the given role assignment."""
return obj.user.get_full_name() if obj.user else ""
user = self._get_user(obj)
return getattr(user.profile, "name", "") if user and hasattr(user, "profile") else ""

def get_email(self, obj: UserAssignments) -> str:
"""Get the email for the given role assignment."""
return getattr(obj.user, "email", "") if obj.user else ""

def get_assignation_count(self, obj: UserAssignments) -> int:
"""Get the assignation count for the given role assignment."""
def get_assignment_count(self, obj: UserAssignments) -> int:
"""Get the assignment count for the given role assignment."""
return len(obj.assignments)

def get_assignments(self, obj: UserAssignments) -> list[dict]:
"""Return the first N assignment records, limited by assignments_limit from context."""
limit = self.context.get("assignments_limit", ListTeamMembersSerializer.ASSIGNMENTS_LIMIT_DEFAULT)
limited_assignments = obj.assignments[:limit]
return TeamMemberAssignmentInlineSerializer(
limited_assignments,
many=True,
).data


class UserValidationAPIViewSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Serializer for validating user existence."""
Expand Down Expand Up @@ -434,16 +460,43 @@ def get_permission_count(self, obj: api.RoleAssignmentData | api.SuperAdminAssig
return len(obj.roles[0].permissions) if obj.roles else 0


class TeamMemberAssignmentInlineSerializer(TeamMemberAssignmentSerializer): # pylint: disable=abstract-method
"""Compact serializer for assignment records inlined into the team-members response.

Reuses role, org, scope, and permission_count from TeamMemberAssignmentSerializer.
Adds scope_display_name and drops is_superadmin which is not needed inline.
"""

scope_display_name = serializers.SerializerMethodField()

def get_scope_display_name(self, _obj: api.RoleAssignmentData) -> str:
"""Return an empty placeholder; the view injects the real value post-pagination."""
return ""

def to_representation(self, instance):
"""Remove is_superadmin from the serialized output."""
data = super().to_representation(instance)
data.pop("is_superadmin", None)
return data


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_user(self, obj: api.UserAssignmentData | api.SuperAdminAssignmentData) -> User | None:
"""Get the user object from the pre-fetched user map in context."""
user_map = self.context.get("user_map", {})
username = getattr(obj.user, "username", None)
return user_map.get(username) if username else None

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 ""
user = self._get_user(obj)
return getattr(user.profile, "name", "") if user and hasattr(user, "profile") else ""

def get_username(self, obj: api.UserAssignmentData | api.SuperAdminAssignmentData) -> str:
"""Get username."""
Expand Down
Loading