diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2488c34b..88bfebdf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 ******************* diff --git a/openedx_authz/__init__.py b/openedx_authz/__init__.py index 52cd779d..033c384d 100644 --- a/openedx_authz/__init__.py +++ b/openedx_authz/__init__.py @@ -4,6 +4,6 @@ import os -__version__ = "1.23.0" +__version__ = "1.24.0" ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) diff --git a/openedx_authz/api/utils.py b/openedx_authz/api/utils.py index 386cba0f..07307707 100644 --- a/openedx_authz/api/utils.py +++ b/openedx_authz/api/utils.py @@ -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]: @@ -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 diff --git a/openedx_authz/rest_api/v1/serializers.py b/openedx_authz/rest_api/v1/serializers.py index a62572a8..e4fee89d 100644 --- a/openedx_authz/rest_api/v1/serializers.py +++ b/openedx_authz/rest_api/v1/serializers.py @@ -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 @@ -321,7 +330,14 @@ class TeamMemberSerializer(serializers.Serializer): # pylint: disable=abstract- username = serializers.SerializerMethodField() full_name = serializers.SerializerMethodField() 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.""" @@ -329,16 +345,26 @@ def get_username(self, obj: UserAssignments) -> str: 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.""" @@ -434,6 +460,26 @@ 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.""" @@ -441,9 +487,16 @@ class TeamMemberUserAssignmentSerializer(TeamMemberAssignmentSerializer): # pyl 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.""" diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 441df5e7..97bfa88f 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -38,7 +38,7 @@ get_user_role_assignments_per_scope_type, get_visible_user_role_assignments_filtered_by_current_user, ) -from openedx_authz.api.utils import get_user_map +from openedx_authz.api.utils import get_scope_display_name_map, get_user_map from openedx_authz.constants import permissions from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus, ScopesQuerySetFields, ScopesTypeField @@ -970,6 +970,7 @@ class TeamMembersAPIView(APIView): - scopes (Optional): Comma-separated list of scopes to filter by (e.g., 'lib:Org1:LIB1') - orgs (Optional): Comma-separated list of orgs to filter by (e.g., 'Org1,Org2') - search (Optional): Search term to filter users by username, full name, or email + - assignments_limit (Optional): Maximum number of assignments for each user. Defaults to 3, maximum 10 - sort_by (Optional): Field to sort by. Options: username, full_name, email. Defaults to username - order (Optional): Sort order, 'asc' or 'desc'. Defaults to asc - page (Optional): Page number for pagination @@ -982,7 +983,14 @@ class TeamMembersAPIView(APIView): - username: The user's username - full_name: The user's full name - email: The user's email address - - assignation_count: The number of role assignments the user has + - assignment_count: The number of role assignments the user has + - assignments: A list of the user's role assignments (limited by assignments_limit), each containing: + - role: The role name (e.g., 'library_admin') + - org: The org over which this role is applied + - scope: The scope over which this role is applied + - scope_display_name: The human-readable display name for the scope + (empty string for glob scopes or unresolvable resources) + - permission_count: The number of permissions that apply to this role **Authentication and Permissions** @@ -1004,13 +1012,52 @@ class TeamMembersAPIView(APIView): "username": "jane_doe", "full_name": "Jane Doe", "email": "jane_doe@example.com", - "assignation_count": 3 + "assignment_count": 3, + "assignments": [ + { + "role": "library_admin", + "org": "Org1", + "scope": "lib:Org1:LIB1", + "scope_display_name": "Intro to CS Library", + "permission_count": 11 + }, + { + "role": "course_staff", + "org": "Org1", + "scope": "course-v1:Org1+CS101+2024", + "scope_display_name": "Introduction to Computer Science", + "permission_count": 27 + }, + { + "role": "library_admin", + "org": "Org1", + "scope": "lib:Org1:*", + "scope_display_name": "", + "permission_count": 11 + } + ] }, { "username": "john_doe", "full_name": "John Doe", "email": "john_doe@example.com", - "assignation_count": 1 + "assignment_count": 2, + "assignments": [ + { + "role": "course_staff", + "org": "Org2", + "scope": "course-v1:Org2+*", + "scope_display_name": "", + "permission_count": 27 + }, + { + "role": "library_user", + "org": "*", + "scope": "lib:*", + "scope_display_name": "", + "permission_count": 4 + } + ] } ] } @@ -1022,9 +1069,11 @@ class TeamMembersAPIView(APIView): @apidocs.schema( parameters=[ + apidocs.query_parameter("roles", str, description="The roles to query assignments for"), apidocs.query_parameter("scopes", str, description="The scopes to query assignments for"), apidocs.query_parameter("orgs", str, description="The orgs to query assignments for"), apidocs.query_parameter("search", str, description="The search query to filter users by"), + apidocs.query_parameter("assignments_limit", str, description="The limit number of assignments per user."), apidocs.query_parameter("sort_by", str, description="The field to sort by"), apidocs.query_parameter("order", str, description="The order to sort by"), apidocs.query_parameter("page", int, description="Page number for pagination"), @@ -1044,7 +1093,7 @@ class TeamMembersAPIView(APIView): ] ) def get(self, request: HttpRequest) -> Response: - """Retrieve all users that have at least one assignation according to the filtering fields.""" + """Retrieve all users that have at least one assignment according to the filtering fields.""" serializer = ListTeamMembersSerializer(data=request.query_params) serializer.is_valid(raise_exception=True) query_params = serializer.validated_data @@ -1052,15 +1101,41 @@ def get(self, request: HttpRequest) -> Response: users_with_assignments = api.get_visible_role_assignments_for_user( orgs=query_params.get("orgs"), scopes=query_params.get("scopes"), + roles=query_params.get("roles"), allowed_for_user_external_key=request.user.username, ) - team_members = TeamMemberSerializer(users_with_assignments, many=True).data + usernames = {uwa.user.username for uwa in users_with_assignments if uwa.user} + user_map = get_user_map(usernames) + + team_members = TeamMemberSerializer( + users_with_assignments, + many=True, + context={ + "assignments_limit": query_params.get("assignments_limit"), + "user_map": user_map, + }, + ).data for backend in self.filter_backends: team_members = backend().filter_queryset(request, team_members, self) paginator = self.pagination_class() paginated_response_data = paginator.paginate_queryset(team_members, request) + + # Resolve scope display names only for the current page to avoid + # unnecessary DB lookups for assignments that are not in the response. + scope_keys: set[str] = set() + for member in paginated_response_data: + for assignment in member.get("assignments", []): + scope_key = assignment.get("scope", "") + if scope_key: + scope_keys.add(scope_key) + + scope_display_name_map = get_scope_display_name_map(scope_keys) + for member in paginated_response_data: + for assignment in member.get("assignments", []): + assignment["scope_display_name"] = scope_display_name_map.get(assignment.get("scope", ""), "") + return paginator.get_paginated_response(paginated_response_data) @@ -1385,7 +1460,14 @@ def get(self, request: HttpRequest) -> Response: for assignment in uwa.assignments ] - assignments = TeamMemberUserAssignmentSerializer(user_role_assignments, many=True).data + usernames = {ura.user.username for ura in user_role_assignments if ura.user} + user_map = get_user_map(usernames) + + assignments = TeamMemberUserAssignmentSerializer( + user_role_assignments, + many=True, + context={"user_map": user_map}, + ).data for backend in self.filter_backends: assignments = backend().filter_queryset(request, assignments, self) diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index bce25a1c..795aac83 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -2531,21 +2531,259 @@ def test_pagination(self, query_params: dict, expected_page_count: int, has_next # ------------------------------------------------------------------ # def test_response_shape(self): - """Each result item contains the expected fields. + """Each result item contains the expected top-level and nested assignment fields. Expected result: - Returns 200 OK. - - Each item has username, full_name, email, and assignation_count. + - Each item has username, full_name, email, assignment_count, and assignments. + - Each nested assignment has role, org, scope, scope_display_name, permission_count. """ response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1"}) self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_assignment_fields = {"role", "org", "scope", "scope_display_name", "permission_count"} for item in response.data["results"]: self.assertIn("username", item) self.assertIn("full_name", item) self.assertIn("email", item) - self.assertIn("assignation_count", item) - self.assertEqual(item["assignation_count"], 1) + self.assertIn("assignment_count", item) + self.assertEqual(item["assignment_count"], 1) + self.assertIn("assignments", item) + self.assertIsInstance(item["assignments"], list) + self.assertGreater(len(item["assignments"]), 0) + for assignment in item["assignments"]: + self.assertEqual(set(assignment.keys()), expected_assignment_fields) + + # ------------------------------------------------------------------ # + # assignments_limit: default and cap # + # ------------------------------------------------------------------ # + + def test_assignments_limit_defaults_to_three(self): + """When assignments_limit is not provided, at most 3 assignments are returned per user. + + Setup: + Assign 5 roles to regular_1 across different scopes so that the user + has more assignments than the default limit. + + Expected result: + - assignments array has exactly 3 entries (the default limit). + - assignment_count is greater than 3, proving truncation occurred. + """ + # regular_1 already has library_user in lib:Org1:LIB1 from setUpClass. + # Add 4 more assignments so total >= 5, exceeding the default limit of 3. + extra_scopes = ["lib:Org2:LIB2", "lib:Org3:LIB3", "lib:Org4:LIB4", "lib:Org5:LIB5"] + for scope in extra_scopes: + assign_role_to_user_in_scope("regular_1", roles.LIBRARY_USER.external_key, scope) + + response = self.client.get(self.url, {"search": "regular_1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = [r for r in response.data["results"] if r["username"] == "regular_1"] + self.assertEqual(len(results), 1) + user_data = results[0] + self.assertGreater(user_data["assignment_count"], 3, "Need >3 assignments to prove the default limit") + self.assertEqual(len(user_data["assignments"]), 3) + + def test_assignments_limit_custom_value(self): + """assignments_limit=2 returns at most 2 assignment entries. + + Expected result: + - assignments array has at most 2 entries. + - assignment_count still reflects the full total. + """ + # Assign enough to exceed the requested limit. + extra_scopes = ["lib:Org2:LIB2", "lib:Org3:LIB3", "lib:Org4:LIB4"] + for scope in extra_scopes: + assign_role_to_user_in_scope("regular_1", roles.LIBRARY_USER.external_key, scope) + + response = self.client.get(self.url, {"search": "regular_1", "assignments_limit": 2}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = [r for r in response.data["results"] if r["username"] == "regular_1"] + self.assertEqual(len(results), 1) + self.assertLessEqual(len(results[0]["assignments"]), 2) + self.assertGreater(results[0]["assignment_count"], 2) + + def test_assignments_limit_capped_at_ten(self): + """assignments_limit values above 10 are silently capped to 10. + + Setup: + Assign 12 roles to regular_2 so the user has more assignments than + the hard maximum of 10. + + Expected result: + - Request with assignments_limit=50 succeeds (200 OK). + - assignment_count is greater than 10 (confirming enough data exists). + - assignments array has exactly 10 entries (the hard cap). + """ + # Add 12 more assignments so total >= 12, which exceeds the hard cap of 10. + extra_scopes = [f"lib:CapOrg{i}:CAPLIB{i}" for i in range(1, 12)] + for scope in extra_scopes: + assign_role_to_user_in_scope("regular_2", roles.LIBRARY_USER.external_key, scope) + + response = self.client.get(self.url, {"search": "regular_2", "assignments_limit": 50}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = [r for r in response.data["results"] if r["username"] == "regular_2"] + self.assertEqual(len(results), 1) + user_data = results[0] + self.assertGreater(user_data["assignment_count"], 10, "Need >10 assignments to prove the cap works") + self.assertEqual(len(user_data["assignments"]), 10) + + # ------------------------------------------------------------------ # + # scope_display_name resolution # + # ------------------------------------------------------------------ # + + def test_scope_display_name_resolved_for_libraries(self): + """scope_display_name is resolved from ContentLibrary.learning_package.title. + + Setup: + Create ContentLibrary DB records with learning_package titles for + the scopes used in the fixture. + + Expected result: + - Assignments for lib:Org1:LIB1 have scope_display_name matching + the learning_package title. + """ + lib_scope = "lib:Org1:LIB1" + org1, _ = Organization.objects.get_or_create(name="Org1", short_name="Org1") + lp1, _ = LearningPackage.objects.get_or_create(title="Intro to CS Library") + ContentLibrary.objects.get_or_create( + slug="LIB1", + org=org1, + defaults={"locator": lib_scope, "title": "Intro to CS Library", "learning_package": lp1}, + ) + + response = self.client.get(self.url, {"scopes": lib_scope}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = [r for r in response.data["results"] if r["username"] == "regular_1"] + self.assertEqual(len(results), 1) + lib_assignments = [a for a in results[0]["assignments"] if a["scope"] == lib_scope] + self.assertGreater(len(lib_assignments), 0, "Expected at least one library assignment") + for assignment in lib_assignments: + self.assertEqual(assignment["scope_display_name"], "Intro to CS Library") + + def test_scope_display_name_resolved_for_courses(self): + """scope_display_name is resolved from CourseOverview.display_name. + + Setup: + Create a CourseOverview DB record with a display_name, then assign + a course role to regular_1 in that scope. + + Expected result: + - The assignment for the course scope has scope_display_name matching + the CourseOverview display_name. + """ + course_scope = "course-v1:Org1+CS101+2024" + CourseOverview.objects.get_or_create( + id=course_scope, defaults={"org": "Org1", "display_name": "Introduction to Computer Science"} + ) + assign_role_to_user_in_scope("regular_1", roles.COURSE_STAFF.external_key, course_scope) + + response = self.client.get(self.url, {"search": "regular_1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = [r for r in response.data["results"] if r["username"] == "regular_1"] + self.assertEqual(len(results), 1) + course_assignments = [a for a in results[0]["assignments"] if a["scope"] == course_scope] + self.assertGreater(len(course_assignments), 0, "Expected at least one course assignment") + for assignment in course_assignments: + self.assertEqual(assignment["scope_display_name"], "Introduction to Computer Science") + + def test_scope_display_name_empty_for_missing_resource(self): + """scope_display_name is an empty string when the backing library/course no longer exists. + + Setup: + Assign regular_1 to a scope (lib:OrgX:GONE_LIB) that has no backing + ContentLibrary DB record, simulating a deleted library. + + Expected result: + - The assignment has scope_display_name == "" because the scope cannot + be resolved to a DB record. + """ + orphan_scope = "lib:OrgX:GONE_LIB" + assign_role_to_user_in_scope("regular_1", roles.LIBRARY_USER.external_key, orphan_scope) + + response = self.client.get(self.url, {"search": "regular_1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = [r for r in response.data["results"] if r["username"] == "regular_1"] + self.assertEqual(len(results), 1) + orphan_assignments = [a for a in results[0]["assignments"] if a["scope"] == orphan_scope] + self.assertGreater(len(orphan_assignments), 0, "Expected the orphan scope assignment to be present") + for assignment in orphan_assignments: + self.assertEqual(assignment["scope_display_name"], "") + + def test_scope_display_name_empty_for_glob_scopes(self): + """scope_display_name is an empty string for glob scopes (org-level or platform-level). + + Setup: + Assign regular_1 an org-level glob scope (lib:Org1:*). + + Expected result: + - The glob assignment has scope_display_name == "". + """ + assign_role_to_user_in_scope("regular_1", roles.LIBRARY_ADMIN.external_key, "lib:Org1:*") + + response = self.client.get(self.url, {"search": "regular_1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = [r for r in response.data["results"] if r["username"] == "regular_1"] + self.assertEqual(len(results), 1) + glob_assignments = [a for a in results[0]["assignments"] if a["scope"] == "lib:Org1:*"] + self.assertGreater(len(glob_assignments), 0, "Expected at least one glob scope assignment") + for assignment in glob_assignments: + self.assertEqual(assignment["scope_display_name"], "") + + # ------------------------------------------------------------------ # + # scope_display_name: DB query exception handling # + # ------------------------------------------------------------------ # + + @patch("openedx_authz.api.utils.ContentLibrary") + def test_scope_display_name_graceful_on_library_db_error(self, mock_content_library): + """When the ContentLibrary query raises, scope_display_name falls back to empty string. + + Expected result: + - The endpoint still returns 200 OK. + - scope_display_name is "" for the affected library scopes. + """ + mock_content_library.objects.filter.return_value.select_related.side_effect = Exception("DB error") + + response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = [r for r in response.data["results"] if r["username"] == "regular_1"] + self.assertEqual(len(results), 1) + lib_assignments = [a for a in results[0]["assignments"] if a["scope"] == "lib:Org1:LIB1"] + self.assertGreater(len(lib_assignments), 0, "Expected at least one library assignment") + for assignment in lib_assignments: + self.assertEqual(assignment["scope_display_name"], "") + + @patch("openedx_authz.api.utils.CourseOverview") + def test_scope_display_name_graceful_on_course_db_error(self, mock_course_overview): + """When the CourseOverview query raises, scope_display_name falls back to empty string. + + Setup: + Assign regular_1 a course role so there's a CourseOverviewData scope. + + Expected result: + - The endpoint still returns 200 OK. + - scope_display_name is "" for the affected course scopes. + """ + course_scope = "course-v1:Org1+CS101+2024" + assign_role_to_user_in_scope("regular_1", roles.COURSE_STAFF.external_key, course_scope) + mock_course_overview.objects.filter.side_effect = Exception("DB error") + + response = self.client.get(self.url, {"search": "regular_1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = [r for r in response.data["results"] if r["username"] == "regular_1"] + self.assertEqual(len(results), 1) + course_assignments = [a for a in results[0]["assignments"] if a["scope"] == course_scope] + self.assertGreater(len(course_assignments), 0, "Expected at least one course assignment") + for assignment in course_assignments: + self.assertEqual(assignment["scope_display_name"], "") @ddt