From 68cac6d84efc8726e419e550a8a36ddb159889ba Mon Sep 17 00:00:00 2001 From: jacobo-dominguez-wgu Date: Thu, 12 Nov 2026 16:11:57 -0600 Subject: [PATCH] feat: add search, sort and user counts to the role catalog --- CHANGELOG.rst | 18 ++ src/openedx_authz/api/catalog.py | 4 + src/openedx_authz/api/users.py | 49 +++ src/openedx_authz/rest_api/data.py | 9 + src/openedx_authz/rest_api/utils.py | 77 +++++ .../rest_api/v1/admin_console/filters.py | 61 +++- src/openedx_authz/rest_api/v1/serializers.py | 26 +- src/openedx_authz/rest_api/v1/views.py | 39 ++- src/openedx_authz/tests/api/test_catalog.py | 1 + src/openedx_authz/tests/api/test_users.py | 115 ++++++- .../rest_api/admin_console/test_filters.py | 71 +++++ .../rest_api/admin_console/test_views.py | 45 +++ .../tests/rest_api/test_utils.py | 149 ++++++++- .../tests/rest_api/test_views.py | 287 +++++++++++++++++- 14 files changed, 932 insertions(+), 19 deletions(-) create mode 100644 src/openedx_authz/tests/rest_api/admin_console/test_filters.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cca84522..0e8a0199 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -52,6 +52,22 @@ Added ``openedx_authz/engine/schema/paragon_icons.py`` and regenerated with ``make paragon_icons`` (ADR 0029). * Added the Instructor Dashboard permissions from issue #484 for course information, enrollments, learner grading, learner extensions, and cohorts. The ``enrollments``, ``learner_grading``, and ``learner_administration`` applets define them and add them to the ``course_admin`` and ``course_staff`` roles. +* Added list controls to the role catalog, ``GET /api/authz/v1/roles/``, for the Admin Console + Roles and Permissions table (ADR 0036): + + * ``search`` matches role display names, ignoring case. + * ``definition_kind`` (``static`` or ``user_defined``) keeps only roles of that kind. + * ``sort_by`` (``display_name``, ``scope_type``, ``user_count`` or ``updated_at``) and ``order`` + (``asc`` or ``desc``) sort the roles. Roles without ``updated_at`` go last, and ties are + broken by role identifier. + * Unknown values of ``definition_kind``, ``sort_by`` or ``order`` return 400. + * ``count`` and the pages reflect the filtered and sorted roles. The ``categories`` and + ``permissions`` catalogs are not filtered. + +* Added ``user_count`` and ``updated_at`` to each role of the role catalog. ``user_count`` is the + number of distinct active users with the role in the scopes of the requested types that the + caller can view. ``updated_at`` is ``null`` for static roles. +* Added ``get_visible_role_user_counts`` to the public API to count those users. Changed ======== @@ -61,6 +77,8 @@ Changed * **Breaking**: ``GET /api/authz/v1/scopes/`` no longer accepts ``course`` and ``library`` as ``scope_type`` values. Use ``course-v1`` and ``lib`` instead. Requests with the old values now return a 400 validation error, the same as ``/roles/``. +* The role catalog now lists roles by display name by default, instead of by role identifier. + Pass ``sort_by`` and ``order`` to choose another order. Removed ======= diff --git a/src/openedx_authz/api/catalog.py b/src/openedx_authz/api/catalog.py index 6c65f7d9..d256ca8f 100644 --- a/src/openedx_authz/api/catalog.py +++ b/src/openedx_authz/api/catalog.py @@ -73,6 +73,8 @@ def get_role_catalog(scope_types: list[str]) -> list[dict]: - ``definition_kind``: The :class:`DefinitionKind` value of the role. - ``permissions``: Sorted identifiers of the permissions the role grants in the scope types. - ``scope_types``: Sorted scope types, among the requested ones, in which the role has grants. + - ``updated_at``: When a custom role last changed. Always ``None`` here, because static roles + have no meaningful edit date. """ roles = AuthzRoleDefinition.objects.visible().with_grants_in_scope_types(scope_types) return [ @@ -84,6 +86,8 @@ def get_role_catalog(scope_types: list[str]) -> list[dict]: "definition_kind": DefinitionKind.STATIC.value, "permissions": sorted({grant.permission.identifier for grant in role.grants}), "scope_types": sorted({grant.scope for grant in role.grants}), + # TODO: set correct value for ``updated_at`` when custom roles arrive. + "updated_at": None, } for role in roles.order_by("role_id") ] diff --git a/src/openedx_authz/api/users.py b/src/openedx_authz/api/users.py index 68e9744b..e2eeb8dc 100644 --- a/src/openedx_authz/api/users.py +++ b/src/openedx_authz/api/users.py @@ -10,6 +10,7 @@ """ import logging +from collections import defaultdict from django.contrib.auth import get_user_model from django.db.models import Q @@ -60,6 +61,7 @@ "get_user_role_assignments_filtered", "get_all_user_role_assignments_in_scope", "get_visible_role_assignments_for_user", + "get_visible_role_user_counts", "get_visible_user_role_assignments_filtered_by_current_user", "is_user_allowed", "is_user_allowed_in_any_scope", @@ -398,6 +400,53 @@ def get_visible_role_assignments_for_user( return get_user_assignment_map(user_role_assignments) +def get_visible_role_user_counts( + scope_types: list[str], + allowed_for_user_external_key: str | None = None, +) -> dict[str, int]: + """Count the distinct active users that hold each role in the given scope types. + + Backs the ``user_count`` of the role catalog. It counts users, not assignments: a + user with a role in three courses adds 1 to that role. Only the assignments the viewer can see + count, using the same scope-based visibility as the team members endpoints, and only + active users count, so deactivated accounts don't inflate the Members column. + + Assignments are Casbin grouping policies whose scope column holds full keys or globs, so they + can't be grouped by scope type in SQL. They are loaded once and counted in Python, with one + query for the active users. + + Args: + scope_types: The scope types to count in (e.g., ``['course-v1', 'lib']``). Org and platform + globs of a scope type (e.g., ``lib:Org1:*``) count as that scope type. + allowed_for_user_external_key: The username of the viewer. If None, every assignment counts. + + Returns: + dict[str, int]: The user count by role external key. Roles without active, visible users + are left out. + """ + assignments = [ + assignment + for assignment in get_role_assignments() + if isinstance(assignment.subject, UserData) and assignment.scope.NAMESPACE in scope_types + ] + assignments = _filter_allowed_assignments(assignments, user_external_key=allowed_for_user_external_key) + + active_usernames = set( + User.objects.filter( + username__in={assignment.subject.username for assignment in assignments}, + is_active=True, + ).values_list("username", flat=True) + ) + + usernames_by_role: dict[str, set[str]] = defaultdict(set) + for assignment in assignments: + if assignment.subject.username not in active_usernames: + continue + for role in assignment.roles: + usernames_by_role[role.external_key].add(assignment.subject.username) + return {role: len(usernames) for role, usernames in usernames_by_role.items()} + + def is_user_allowed( user_external_key: str, action_external_key: str, diff --git a/src/openedx_authz/rest_api/data.py b/src/openedx_authz/rest_api/data.py index 7375b9c2..6c4a9a17 100644 --- a/src/openedx_authz/rest_api/data.py +++ b/src/openedx_authz/rest_api/data.py @@ -42,6 +42,15 @@ class UserAssignmentSortField(BaseEnum): EMAIL = "email" +class RoleSortField(BaseEnum): + """Enum for the role catalog fields to sort by.""" + + DISPLAY_NAME = "display_name" + SCOPE_TYPE = "scope_type" + USER_COUNT = "user_count" + UPDATED_AT = "updated_at" + + class SortOrder(BaseEnum): """Enum for the order to sort by.""" diff --git a/src/openedx_authz/rest_api/utils.py b/src/openedx_authz/rest_api/utils.py index 82007b95..ddce9f84 100644 --- a/src/openedx_authz/rest_api/utils.py +++ b/src/openedx_authz/rest_api/utils.py @@ -1,5 +1,7 @@ """Utility functions for the Open edX AuthZ REST API.""" +from datetime import datetime + from openedx_authz.api.data import ( GLOBAL_SCOPE_WILDCARD, ScopeData, @@ -8,6 +10,7 @@ from openedx_authz.rest_api.data import ( AssignmentSortField, BaseEnum, + RoleSortField, ScopesTypeField, SearchField, SerializedAssignment, @@ -204,6 +207,80 @@ def sort_user_assignments( return _sort_by_field(assignments, sort_by, order, UserAssignmentSortField) +def filter_roles(roles: list[dict], search: str | None, definition_kind: str | None) -> list[dict]: + """Filter role catalog entries by display name and definition kind. + + Runs in Python, not in the database, because static role names are translated with gettext + and the search has to match the name the user sees. + + Args: + roles (list[dict]): Role catalog entries, as returned by ``get_role_catalog``. + search (str | None): Optional case-insensitive substring of ``display_name``. + definition_kind (str | None): Optional ``DefinitionKind`` value to keep. + + Returns: + list[dict]: The matching roles, preserving the original order. + """ + if not search and not definition_kind: + return roles + + search = search.lower() if search else None + return [ + role + for role in roles + if (not search or search in (role.get("display_name") or "").lower()) + and (not definition_kind or role.get("definition_kind") == definition_kind) + ] + + +def _role_sort_value(role: dict, sort_by: str) -> str | int | datetime | None: + """Return the value of a role to sort by, or None when the role has no value for the field.""" + if sort_by == RoleSortField.DISPLAY_NAME: + return (role.get("display_name") or "").lower() + if sort_by == RoleSortField.SCOPE_TYPE: + return ",".join(role.get("scope_types") or []) + if sort_by == RoleSortField.USER_COUNT: + return role.get("user_count") or 0 + return role.get("updated_at") + + +def sort_roles( + roles: list[dict], + sort_by: RoleSortField = RoleSortField.DISPLAY_NAME, + order: SortOrder = SortOrder.ASC, +) -> list[dict]: + """Sort role catalog entries by a field and order. + + Unlike :func:`_sort_by_field`, the values are not all strings: ``user_count`` sorts as a + number and ``updated_at`` as a date. Roles without a value (``updated_at`` of static roles) + go last in both orders, so a descending sort still starts with the most recently updated + role. Ties are broken by ``role`` identifier, ascending, so pages are stable. + + Args: + roles (list[dict]): Role catalog entries, with ``user_count`` and ``updated_at`` set. + sort_by (RoleSortField, optional): The field to sort by. Defaults to ``display_name``. + order (SortOrder, optional): The order to sort by. Defaults to ``asc``. + + Raises: + ValueError: If the sort field is invalid. + ValueError: If the sort order is invalid. + + Returns: + list[dict]: The sorted roles. + """ + if sort_by not in RoleSortField.values(): + raise ValueError(f"Invalid field: '{sort_by}'. Must be one of {RoleSortField.values()}") + if order not in SortOrder.values(): + raise ValueError(f"Invalid order: '{order}'. Must be one of {SortOrder.values()}") + + # Sort by identifier first: the sort below is stable, also when reversed, so this breaks ties. + by_role = sorted(roles, key=lambda role: role["role"]) + with_value = [role for role in by_role if _role_sort_value(role, sort_by) is not None] + without_value = [role for role in by_role if _role_sort_value(role, sort_by) is None] + with_value.sort(key=lambda role: _role_sort_value(role, sort_by), reverse=order == SortOrder.DESC) + return with_value + without_value + + def inject_scope_display_names(assignments: list[SerializedAssignment]) -> None: """Set ``scope_display_name`` in-place on serialized assignments using one bulk lookup. diff --git a/src/openedx_authz/rest_api/v1/admin_console/filters.py b/src/openedx_authz/rest_api/v1/admin_console/filters.py index 7d8c275b..94056b3b 100644 --- a/src/openedx_authz/rest_api/v1/admin_console/filters.py +++ b/src/openedx_authz/rest_api/v1/admin_console/filters.py @@ -2,8 +2,21 @@ from rest_framework.filters import BaseFilterBackend -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 +from openedx_authz.rest_api.data import ( + AssignmentSortField, + SearchField, + SortField, + SortOrder, + UserAssignmentSortField, +) +from openedx_authz.rest_api.utils import ( + filter_roles, + filter_users, + sort_assignments, + sort_roles, + sort_user_assignments, + sort_users, +) class TeamMemberSearchFilter(BaseFilterBackend): @@ -15,11 +28,16 @@ def filter_queryset(self, request, queryset, view): class TeamMemberOrderingFilter(BaseFilterBackend): - """Sort team members by a given field and order.""" + """Sort team members by a given field and order. + + An empty ``sort_by`` or ``order`` (e.g. ``?sort_by=``) falls back to the default, as the query + serializer does. Passing the empty string on would make the sort raise ``ValueError`` (a 500). + The other ordering backends below follow the same rule. + """ def filter_queryset(self, request, queryset, view): - sort_by = request.query_params.get("sort_by", SortField.USERNAME) - order = request.query_params.get("order", SortOrder.ASC) + sort_by = request.query_params.get("sort_by") or SortField.USERNAME + order = request.query_params.get("order") or SortOrder.ASC return sort_users(users=queryset, sort_by=sort_by, order=order) @@ -27,8 +45,8 @@ class TeamMemberAssignmentsOrderingFilter(BaseFilterBackend): """Sort team member assignments by a given field and order.""" 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) + sort_by = request.query_params.get("sort_by") or AssignmentSortField.ROLE + order = request.query_params.get("order") or SortOrder.ASC return sort_assignments(assignments=queryset, sort_by=sort_by, order=order) @@ -51,6 +69,31 @@ 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.FULL_NAME) - order = request.query_params.get("order", SortOrder.ASC) + sort_by = request.query_params.get("sort_by") or UserAssignmentSortField.FULL_NAME + order = request.query_params.get("order") or SortOrder.ASC return sort_user_assignments(assignments=queryset, sort_by=sort_by, order=order) + + +class RoleSearchFilter(BaseFilterBackend): + """Filter role catalog entries by display name and definition kind. + + Reads ``view.validated_query_params``, the output of ``ListRolesQuerySerializer``, instead of the + raw query string, so the values are already stripped, lowercased and defaulted. Reading the raw + ``search`` would miss matches for values with surrounding spaces. + """ + + def filter_queryset(self, request, queryset, view): + params = view.validated_query_params + return filter_roles(roles=queryset, search=params["search"], definition_kind=params["definition_kind"]) + + +class RoleOrderingFilter(BaseFilterBackend): + """Sort role catalog entries by a given field and order. + + Reads ``view.validated_query_params``, like :class:`RoleSearchFilter`, so empty values already + carry the serializer defaults and only supported values reach :func:`sort_roles`. + """ + + def filter_queryset(self, request, queryset, view): + params = view.validated_query_params + return sort_roles(roles=queryset, sort_by=params["sort_by"], order=params["order"]) diff --git a/src/openedx_authz/rest_api/v1/serializers.py b/src/openedx_authz/rest_api/v1/serializers.py index 5bd58d0e..88a02324 100644 --- a/src/openedx_authz/rest_api/v1/serializers.py +++ b/src/openedx_authz/rest_api/v1/serializers.py @@ -5,7 +5,7 @@ from openedx_authz import api from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, DefinitionKind -from openedx_authz.rest_api.data import ScopesTypeField, SortField, SortOrder +from openedx_authz.rest_api.data import RoleSortField, ScopesTypeField, SortField, SortOrder from openedx_authz.rest_api.utils import get_generic_scope, parse_scope_types from openedx_authz.rest_api.v1.fields import CommaSeparatedListField, LowercaseCharField @@ -193,9 +193,29 @@ class ListUsersInRoleWithScopeResponseSerializer(serializers.Serializer): # pyl class ListRolesQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method - """Serializer for validating the query parameters of the role catalog.""" + """Serializer for validating the query parameters of the role catalog. + + ``search``, ``sort_by`` and ``order`` reuse the names of the team members endpoints, + and ``definition_kind`` filters by how a role is defined. Unknown values are a 400. + """ scope_types = serializers.CharField() + search = LowercaseCharField(required=False, default=None) + definition_kind = serializers.ChoiceField( + required=False, + choices=[(e.value, e.name) for e in DefinitionKind], + default=None, + ) + sort_by = serializers.ChoiceField( + required=False, + choices=[(e.value, e.name) for e in RoleSortField], + default=RoleSortField.DISPLAY_NAME.value, + ) + order = serializers.ChoiceField( + required=False, + choices=[(e.value, e.name) for e in SortOrder], + default=SortOrder.ASC.value, + ) def validate_scope_types(self, value: str) -> list[str]: """Convert the comma-separated scope types to a list of unique, supported scope types. @@ -242,6 +262,8 @@ class RoleCatalogRoleSerializer(serializers.Serializer): # pylint: disable=abst definition_kind = serializers.ChoiceField(choices=[(e.value, e.name) for e in DefinitionKind]) permissions = serializers.ListField(child=serializers.CharField(max_length=255)) scope_types = serializers.ListField(child=serializers.CharField(max_length=255)) + user_count = serializers.IntegerField() + updated_at = serializers.DateTimeField(allow_null=True) class RoleCatalogMetaSerializer(serializers.Serializer): # pylint: disable=abstract-method diff --git a/src/openedx_authz/rest_api/v1/views.py b/src/openedx_authz/rest_api/v1/views.py index 3143e608..9fb43cb1 100644 --- a/src/openedx_authz/rest_api/v1/views.py +++ b/src/openedx_authz/rest_api/v1/views.py @@ -31,6 +31,7 @@ from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus from openedx_authz.rest_api.decorators import authz_permissions, view_auth_classes from openedx_authz.rest_api.utils import filter_users, sort_users +from openedx_authz.rest_api.v1.admin_console.filters import RoleOrderingFilter, RoleSearchFilter from openedx_authz.rest_api.v1.paginators import AuthZAPIViewPagination from openedx_authz.rest_api.v1.permissions import AnyScopePermission, DynamicScopePermission, ScopeTypePermission from openedx_authz.rest_api.v1.serializers import ( @@ -420,17 +421,24 @@ class RoleListView(APIView): - scope_types (Required): Comma-separated list of scope types to query, with at least one value. Each one is ``course-v1`` or ``lib``. + - search (Optional): Case-insensitive search on the role display name + - definition_kind (Optional): Only list roles of this kind: ``static`` or ``user_defined`` + - sort_by (Optional): Field to sort by. Options: display_name, scope_type, user_count, updated_at. + Defaults to display_name. Roles without ``updated_at`` go last; ties are broken by role. + - order (Optional): Sort order, 'asc' or 'desc'. Defaults to asc - page (Optional): Page number for pagination of the roles - page_size (Optional): Number of roles per page **Response Format** - - count, next, previous: Pagination of the roles + - count, next, previous: Pagination of the roles, after search and filtering - scope_types: The requested scope types (``course-v1``, ``lib``) - categories: Categories used by the permissions (id, display_name, description, icon) - permissions: Permissions of the scope types (id, display_name, description, icon, category_id, scope_types) - results: Roles, each with role, display_name, description, icon, definition_kind, permissions - (ids of entries of ``permissions``), scope_types (requested scope types in which the role has grants) + (ids of entries of ``permissions``), scope_types (requested scope types in which the role has grants), + user_count (distinct active users with the role in the scopes of those types the caller can view) + and updated_at (when a custom role last changed; null for static roles) **Authentication and Permissions** @@ -442,16 +450,34 @@ class RoleListView(APIView): **Example Request** GET /api/authz/v1/roles/?scope_types=course-v1&page=1&page_size=10 + + GET /api/authz/v1/roles/?scope_types=course-v1,lib&search=admin&sort_by=user_count&order=desc """ pagination_class = AuthZAPIViewPagination + filter_backends = [RoleSearchFilter, RoleOrderingFilter] permission_classes = [ScopeTypePermission] + # Set by ``get`` from ``ListRolesQuerySerializer`` and read by the filter backends. + validated_query_params: dict | None = None @apidocs.schema( parameters=[ apidocs.query_parameter( "scope_types", str, description="Comma-separated scope types to query: `course-v1` and/or `lib`" ), + apidocs.query_parameter("search", str, description="Search roles by display name"), + apidocs.query_parameter( + "definition_kind", str, description="Only list roles of this kind: `static` or `user_defined`" + ), + apidocs.query_parameter( + "sort_by", + str, + description="The field to sort by. Options: display_name, scope_type, user_count, updated_at. " + "Defaults to display_name", + ), + apidocs.query_parameter( + "order", str, description="The order to sort by. Options: asc, desc. Defaults to asc" + ), apidocs.query_parameter("page", int, description="Page number for pagination"), apidocs.query_parameter("page_size", int, description="Number of roles per page"), ], @@ -466,10 +492,17 @@ def get(self, request: HttpRequest) -> Response: """Retrieve the roles, permissions and categories available for one or more scope types.""" query_serializer = ListRolesQuerySerializer(data=request.query_params) query_serializer.is_valid(raise_exception=True) - scope_types = query_serializer.validated_data["scope_types"] + # The filter backends read the validated values, so the serializer is the only parser. + self.validated_query_params = query_serializer.validated_data + scope_types = self.validated_query_params["scope_types"] permission_catalog = api.get_permission_catalog(scope_types) role_catalog = api.get_role_catalog(scope_types) + user_counts = api.get_visible_role_user_counts(scope_types, request.user.username) + for role in role_catalog: + role["user_count"] = user_counts.get(role["role"], 0) + for backend in self.filter_backends: + role_catalog = backend().filter_queryset(request, role_catalog, self) paginator = self.pagination_class() page = paginator.paginate_queryset(role_catalog, request) diff --git a/src/openedx_authz/tests/api/test_catalog.py b/src/openedx_authz/tests/api/test_catalog.py index 948451ec..971ab983 100644 --- a/src/openedx_authz/tests/api/test_catalog.py +++ b/src/openedx_authz/tests/api/test_catalog.py @@ -114,6 +114,7 @@ def test_returns_role_with_grants_in_scope_types(self): "definition_kind": DefinitionKind.STATIC.value, "permissions": ["common.manage_team", "courses.view_course"], "scope_types": ["course-v1"], + "updated_at": None, } ], ) diff --git a/src/openedx_authz/tests/api/test_users.py b/src/openedx_authz/tests/api/test_users.py index b94e662c..cc9ee77c 100644 --- a/src/openedx_authz/tests/api/test_users.py +++ b/src/openedx_authz/tests/api/test_users.py @@ -30,6 +30,7 @@ get_user_role_assignments_in_scope, get_user_role_assignments_per_scope_type, get_visible_role_assignments_for_user, + get_visible_role_user_counts, get_visible_user_role_assignments_filtered_by_current_user, is_user_allowed, is_user_allowed_in_any_scope, @@ -57,7 +58,7 @@ MANAGE_LIBRARY_TEAM, PUBLISH_LIBRARY_CONTENT, ) -from openedx_authz.tests.api.test_roles import RolesTestSetupMixin +from openedx_authz.tests.api.test_roles import BaseRolesTestCase, RolesTestSetupMixin class UserAssignmentsSetupMixin(RolesTestSetupMixin): @@ -945,6 +946,118 @@ def test_prefilter_is_applied_before_authorization(self): self.assertTrue(any(r.external_key == "library_admin" for r in a.roles)) +@ddt +class TestGetVisibleRoleUserCounts(BaseRolesTestCase): + """Tests for get_visible_role_user_counts, which backs the role catalog ``user_count``.""" + + _ASSIGNMENTS = [ + # alice can view the team of lib:Org1:math_101 only + {"subject_name": "alice", "role_name": LIBRARY_ADMIN.external_key, "scope_name": "lib:Org1:math_101"}, + # bob has the same role in two libraries + {"subject_name": "bob", "role_name": LIBRARY_AUTHOR.external_key, "scope_name": "lib:Org1:math_101"}, + {"subject_name": "bob", "role_name": LIBRARY_AUTHOR.external_key, "scope_name": "lib:Org1:history_201"}, + # carol is in a library alice cannot view + {"subject_name": "carol", "role_name": LIBRARY_AUTHOR.external_key, "scope_name": "lib:Org2:physics_401"}, + # dave is inactive + {"subject_name": "dave", "role_name": LIBRARY_USER.external_key, "scope_name": "lib:Org1:math_101"}, + # erin is in an org glob, which counts as the ``lib`` scope type + {"subject_name": "erin", "role_name": LIBRARY_USER.external_key, "scope_name": "lib:Org2:*"}, + # frank is in a course + { + "subject_name": "frank", + "role_name": COURSE_STAFF.external_key, + "scope_name": "course-v1:Org1+CS101+2024", + }, + ] + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + User = get_user_model() + for username in ["alice", "bob", "carol", "erin", "frank", "nobody"]: + User.objects.create_user(username=username, email=f"{username}@example.com") + User.objects.create_user(username="dave", email="dave@example.com", is_active=False) + User.objects.create_user(username="staff_viewer", email="staff_viewer@example.com", is_staff=True) + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Assign as users (``user^`` subjects); the base helper assigns generic ``sub^`` subjects. + for assignment in cls._ASSIGNMENTS: + assign_role_to_user_in_scope(assignment["subject_name"], assignment["role_name"], assignment["scope_name"]) + + def test_counts_distinct_active_users_per_role(self): + """Without a viewer, every active user counts once per role. + + Expected result: + - bob counts once although he has the role in two libraries + - dave, who is inactive, is not counted + - erin, in an org glob, counts as a library user + - frank's course assignment is left out of the library counts + """ + result = get_visible_role_user_counts(["lib"]) + + self.assertEqual( + result, + { + LIBRARY_ADMIN.external_key: 1, + LIBRARY_AUTHOR.external_key: 2, + LIBRARY_USER.external_key: 1, + }, + ) + + @data( + (["course-v1"], {COURSE_STAFF.external_key: 1}), + ( + ["course-v1", "lib"], + { + LIBRARY_ADMIN.external_key: 1, + LIBRARY_AUTHOR.external_key: 2, + LIBRARY_USER.external_key: 1, + COURSE_STAFF.external_key: 1, + }, + ), + (["unknown"], {}), + ) + @unpack + def test_counts_only_requested_scope_types(self, scope_types: list[str], expected: dict[str, int]): + """Only assignments in the requested scope types count. + + Expected result: + - The counts match the assignments of the requested scope types + """ + self.assertEqual(get_visible_role_user_counts(scope_types), expected) + + def test_viewer_only_counts_scopes_it_can_view(self): + """A viewer only counts the assignments in scopes where it can view the team. + + Expected result: + - alice counts herself and bob in lib:Org1:math_101, but not carol, erin or bob's other library + """ + result = get_visible_role_user_counts(["lib"], allowed_for_user_external_key="alice") + + self.assertEqual(result, {LIBRARY_ADMIN.external_key: 1, LIBRARY_AUTHOR.external_key: 1}) + + def test_staff_viewer_counts_everything(self): + """A staff viewer counts every assignment, as it can view every team. + + Expected result: + - The counts are the same as without a viewer + """ + self.assertEqual( + get_visible_role_user_counts(["lib"], allowed_for_user_external_key="staff_viewer"), + get_visible_role_user_counts(["lib"]), + ) + + def test_viewer_without_roles_counts_nothing(self): + """A viewer without role assignments cannot view any team. + + Expected result: + - No role is counted + """ + self.assertEqual(get_visible_role_user_counts(["lib"], allowed_for_user_external_key="nobody"), {}) + + class TestExpandScopesWithAncestors(UserAssignmentsSetupMixin): """Unit tests for ScopeData.expand_keys_with_ancestors.""" diff --git a/src/openedx_authz/tests/rest_api/admin_console/test_filters.py b/src/openedx_authz/tests/rest_api/admin_console/test_filters.py new file mode 100644 index 00000000..5cfdd44c --- /dev/null +++ b/src/openedx_authz/tests/rest_api/admin_console/test_filters.py @@ -0,0 +1,71 @@ +"""Unit tests for the role catalog filter backends in openedx_authz.rest_api.v1.admin_console.filters.""" + +from types import SimpleNamespace + +from ddt import data, ddt, unpack +from django.test import TestCase +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory + +from openedx_authz.api.data import DefinitionKind +from openedx_authz.rest_api.v1.admin_console.filters import RoleOrderingFilter, RoleSearchFilter +from openedx_authz.rest_api.v1.serializers import ListRolesQuerySerializer + +ROLES = [ + {"role": "library_user", "display_name": "Library User", "definition_kind": DefinitionKind.STATIC.value}, + {"role": "custom_role", "display_name": "Grader", "definition_kind": DefinitionKind.USER_DEFINED.value}, + {"role": "library_author", "display_name": "Library Author", "definition_kind": DefinitionKind.STATIC.value}, +] + + +def _request_and_view(query_params: dict) -> tuple[Request, SimpleNamespace]: + """Build a request and a view carrying the parameters as ``RoleListView`` validates them.""" + request = Request(APIRequestFactory().get("/", {"scope_types": "lib", **query_params})) + serializer = ListRolesQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + return request, SimpleNamespace(validated_query_params=serializer.validated_data) + + +@ddt +class TestRoleSearchFilter(TestCase): + """Tests for RoleSearchFilter.""" + + @data( + ({}, ["library_user", "custom_role", "library_author"]), + ({"search": "LIBRARY"}, ["library_user", "library_author"]), + ({"definition_kind": "user_defined"}, ["custom_role"]), + ({"search": "library", "definition_kind": "user_defined"}, []), + ({"definition_kind": ""}, ["library_user", "custom_role", "library_author"]), + # Regression: the validated search is stripped, so surrounding spaces still match + ({"search": " author "}, ["library_author"]), + ) + @unpack + def test_filters_by_query_params(self, query_params: dict, expected: list[str]): + """The search and definition_kind query parameters filter the roles.""" + request, view = _request_and_view(query_params) + + result = RoleSearchFilter().filter_queryset(request, ROLES, view=view) + + self.assertEqual([role["role"] for role in result], expected) + + +@ddt +class TestRoleOrderingFilter(TestCase): + """Tests for RoleOrderingFilter.""" + + @data( + ({}, ["library_author", "library_user", "custom_role"]), + ({"sort_by": "", "order": ""}, ["library_author", "library_user", "custom_role"]), + ({"order": "desc"}, ["custom_role", "library_user", "library_author"]), + ({"sort_by": "display_name", "order": "asc"}, ["library_author", "library_user", "custom_role"]), + ) + @unpack + def test_sorts_by_query_params(self, query_params: dict, expected: list[str]): + """Roles are sorted by sort_by and order, defaulting to display name ascending.""" + roles = [{**role, "display_name": name} for role, name in zip(ROLES, ["B", "C", "A"])] + + request, view = _request_and_view(query_params) + + result = RoleOrderingFilter().filter_queryset(request, roles, view=view) + + self.assertEqual([role["role"] for role in result], expected) diff --git a/src/openedx_authz/tests/rest_api/admin_console/test_views.py b/src/openedx_authz/tests/rest_api/admin_console/test_views.py index 835fedf7..c4e54a0c 100644 --- a/src/openedx_authz/tests/rest_api/admin_console/test_views.py +++ b/src/openedx_authz/tests/rest_api/admin_console/test_views.py @@ -1241,6 +1241,21 @@ def setUp(self): super().setUp() self.url = reverse("openedx_authz:user-list") + def test_empty_sort_params_use_the_defaults(self): + """Regression: an empty ``sort_by`` or ``order`` falls back to the default instead of a 500. + + Expected result: + - Returns 200 OK with the same order as when the parameters are omitted + """ + default = self.client.get(self.url) + empty = self.client.get(self.url, {"sort_by": "", "order": ""}) + + self.assertEqual(empty.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["username"] for item in empty.data["results"]], + [item["username"] for item in default.data["results"]], + ) + # -------------------------------------------------------------------- # # Visibility: calling user only sees assignments it has view access to # # -------------------------------------------------------------------- # @@ -1735,6 +1750,21 @@ class TestTeamMemberAssignmentsAPIView(ViewTestMixin): def _url(self, username: str) -> str: return reverse("openedx_authz:user-assignment-list", kwargs={"username": username}) + def test_empty_sort_params_use_the_defaults(self): + """Regression: an empty ``sort_by`` or ``order`` falls back to the default instead of a 500. + + Expected result: + - Returns 200 OK with the same order as when the parameters are omitted + """ + default = self.client.get(self._url("regular_6")) + empty = self.client.get(self._url("regular_6"), {"sort_by": "", "order": ""}) + + self.assertEqual(empty.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["role"] for item in empty.data["results"]], + [item["role"] for item in default.data["results"]], + ) + # -------------------------------------------------------------------- # # Visibility: calling user only sees assignments it has view access to # # -------------------------------------------------------------------- # @@ -2133,6 +2163,21 @@ def setUp(self): super().setUp() self.url = reverse("openedx_authz:assignment-list") + def test_empty_sort_params_use_the_defaults(self): + """Regression: an empty ``sort_by`` or ``order`` falls back to the default instead of a 500. + + Expected result: + - Returns 200 OK with the same order as when the parameters are omitted + """ + default = self.client.get(self.url) + empty = self.client.get(self.url, {"sort_by": "", "order": ""}) + + self.assertEqual(empty.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["username"] for item in empty.data["results"]], + [item["username"] for item in default.data["results"]], + ) + # -------------------------------------------------------------------- # # Visibility: calling user only sees assignments it has view access to # # -------------------------------------------------------------------- # diff --git a/src/openedx_authz/tests/rest_api/test_utils.py b/src/openedx_authz/tests/rest_api/test_utils.py index 1678eaec..6831c121 100644 --- a/src/openedx_authz/tests/rest_api/test_utils.py +++ b/src/openedx_authz/tests/rest_api/test_utils.py @@ -1,9 +1,154 @@ """Unit tests for openedx_authz.rest_api.utils.""" +from datetime import datetime, timezone + +from ddt import data, ddt, unpack from django.test import TestCase -from openedx_authz.rest_api.data import AssignmentSortField -from openedx_authz.rest_api.utils import sort_assignments +from openedx_authz.api.data import DefinitionKind +from openedx_authz.rest_api.data import AssignmentSortField, RoleSortField, SortOrder +from openedx_authz.rest_api.utils import filter_roles, sort_assignments, sort_roles + + +def _role(role: str, display_name: str, **fields) -> dict: + """Build a role catalog entry with defaults for the fields a test does not care about.""" + return { + "role": role, + "display_name": display_name, + "definition_kind": DefinitionKind.STATIC.value, + "scope_types": ["lib"], + "user_count": 0, + "updated_at": None, + **fields, + } + + +EARLY = datetime(2026, 1, 1, tzinfo=timezone.utc) +LATE = datetime(2026, 6, 1, tzinfo=timezone.utc) +ROLES = [ + _role("library_user", "Library User", user_count=3), + _role("course_staff", "course staff", scope_types=["course-v1"], user_count=10), + _role("library_author", "Library Author", user_count=3), + _role("custom_b", "Reviewer", definition_kind=DefinitionKind.USER_DEFINED.value, updated_at=EARLY), + _role( + "custom_a", + "Grader", + definition_kind=DefinitionKind.USER_DEFINED.value, + scope_types=["course-v1", "lib"], + updated_at=LATE, + ), +] + + +@ddt +class TestFilterRoles(TestCase): + """Tests for filter_roles.""" + + @data( + (None, None, ["library_user", "course_staff", "library_author", "custom_b", "custom_a"]), + ("library", None, ["library_user", "library_author"]), + # The search is case-insensitive and matches anywhere in the display name + ("STAFF", None, ["course_staff"]), + ("rary aut", None, ["library_author"]), + ("nothing", None, []), + (None, DefinitionKind.USER_DEFINED.value, ["custom_b", "custom_a"]), + (None, DefinitionKind.STATIC.value, ["library_user", "course_staff", "library_author"]), + ("er", DefinitionKind.USER_DEFINED.value, ["custom_b", "custom_a"]), + ("library", DefinitionKind.USER_DEFINED.value, []), + ) + @unpack + def test_filters_and_preserves_order(self, search: str | None, definition_kind: str | None, expected: list[str]): + """Roles matching both filters are kept in their original order.""" + result = filter_roles(ROLES, search, definition_kind) + + self.assertEqual([role["role"] for role in result], expected) + + def test_does_not_search_the_role_identifier(self): + """The search matches the display name only, not the role identifier.""" + self.assertEqual(filter_roles(ROLES, "custom", None), []) + + +@ddt +class TestSortRoles(TestCase): + """Tests for sort_roles.""" + + @data( + # Display names are compared case-insensitively + ( + RoleSortField.DISPLAY_NAME, + SortOrder.ASC, + ["course_staff", "custom_a", "library_author", "library_user", "custom_b"], + ), + ( + RoleSortField.DISPLAY_NAME, + SortOrder.DESC, + ["custom_b", "library_user", "library_author", "custom_a", "course_staff"], + ), + # Ties ("lib") are broken by role identifier in both orders + ( + RoleSortField.SCOPE_TYPE, + SortOrder.ASC, + ["course_staff", "custom_a", "custom_b", "library_author", "library_user"], + ), + ( + RoleSortField.SCOPE_TYPE, + SortOrder.DESC, + ["custom_b", "library_author", "library_user", "custom_a", "course_staff"], + ), + # Counts sort as numbers (10 > 3), ties by role identifier + ( + RoleSortField.USER_COUNT, + SortOrder.ASC, + ["custom_a", "custom_b", "library_author", "library_user", "course_staff"], + ), + ( + RoleSortField.USER_COUNT, + SortOrder.DESC, + ["course_staff", "library_author", "library_user", "custom_a", "custom_b"], + ), + # Roles without updated_at go last in both orders + ( + RoleSortField.UPDATED_AT, + SortOrder.ASC, + ["custom_b", "custom_a", "course_staff", "library_author", "library_user"], + ), + ( + RoleSortField.UPDATED_AT, + SortOrder.DESC, + ["custom_a", "custom_b", "course_staff", "library_author", "library_user"], + ), + ) + @unpack + def test_sorts_by_field_and_order(self, sort_by: str, order: str, expected: list[str]): + """Roles are sorted by the field, in the order, with stable tie-breaks.""" + result = sort_roles(ROLES, sort_by, order) + + self.assertEqual([role["role"] for role in result], expected) + + def test_defaults_to_display_name_ascending(self): + """Without arguments, roles are sorted by display name, ascending.""" + self.assertEqual(sort_roles(ROLES), sort_roles(ROLES, RoleSortField.DISPLAY_NAME, SortOrder.ASC)) + + def test_does_not_modify_the_input(self): + """The input list keeps its order.""" + roles = list(ROLES) + + sort_roles(roles, RoleSortField.USER_COUNT, SortOrder.DESC) + + self.assertEqual(roles, ROLES) + + def test_empty_list(self): + """No roles means nothing to sort.""" + self.assertEqual(sort_roles([]), []) + + @data(({"sort_by": "role"}, "Invalid field"), ({"order": "ascending"}, "Invalid order")) + @unpack + def test_invalid_arguments_raise_value_error(self, kwargs: dict, message: str): + """An unknown sort field or order raises ValueError.""" + with self.assertRaises(ValueError) as ctx: + sort_roles(ROLES, **kwargs) + + self.assertIn(message, str(ctx.exception)) class TestSortAssignments(TestCase): diff --git a/src/openedx_authz/tests/rest_api/test_views.py b/src/openedx_authz/tests/rest_api/test_views.py index a72895d5..c1b091e6 100644 --- a/src/openedx_authz/tests/rest_api/test_views.py +++ b/src/openedx_authz/tests/rest_api/test_views.py @@ -1297,7 +1297,7 @@ def test_get_roles_catalog(self): Expected result: - Returns 200 OK with scopes, categories, permissions and roles of the namespace only - Roles reference permissions by id, hidden roles and unused categories are left out - - Everything is ordered by identifier + - Categories and permissions are ordered by identifier, roles by display name """ response = self.client.get(self.url, {"scope_types": "lib"}) @@ -1338,7 +1338,8 @@ def test_get_roles_catalog(self): self.assertEqual(by_role[LIBRARY_AUTHOR.external_key]["definition_kind"], DefinitionKind.STATIC.value) self.assertEqual(by_role[LIBRARY_AUTHOR.external_key]["scope_types"], ["lib"]) self.assertEqual(by_role[LIBRARY_USER.external_key]["display_name"], "Library User") - self.assertEqual(list(by_role), sorted(by_role)) + display_names = [r["display_name"] for r in response.data["results"]] + self.assertEqual(display_names, sorted(display_names, key=str.lower)) def test_get_roles_permission_without_category(self): """A permission without a category is listed with a null category_id. @@ -1476,6 +1477,288 @@ def test_get_roles_pagination_only_paginates_roles(self): self.assertEqual(first.data["permissions"], second.data["permissions"]) self.assertEqual(first.data["categories"], second.data["categories"]) + def _get_user_counts(self, user: User) -> dict[str, int]: + """Return the ``user_count`` of each library role as seen by the given user.""" + self.client.force_authenticate(user=user) + response = self.client.get(self.url, {"scope_types": "lib"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + return {r["role"]: r["user_count"] for r in response.data["results"]} + + def test_get_roles_static_role_has_user_count_and_null_updated_at(self): + """Every role carries ``user_count`` and ``updated_at``. + + Expected result: + - ``user_count`` is an integer and ``updated_at`` is null for static roles + """ + response = self.client.get(self.url, {"scope_types": "lib"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for role in response.data["results"]: + self.assertIsInstance(role["user_count"], int) + self.assertIsNone(role["updated_at"]) + + def test_get_roles_user_count_counts_distinct_active_users(self): + """``user_count`` counts distinct active users, not assignments. + + Expected result: + - A user with the role in two scopes adds 1 + - An inactive user adds nothing + """ + before = self._get_user_counts(self.admin_user) + User.objects.filter(username="regular_7").update(is_active=False) + self._assign_roles_to_users( + assignments=[ + {"subject_name": "regular_6", "role_name": LIBRARY_USER.external_key, "scope_name": scope} + for scope in ("lib:Org3:LIB3", "lib:Org9:OTHER") + ] + + [ + { + "subject_name": "regular_7", + "role_name": LIBRARY_USER.external_key, + "scope_name": "lib:Org9:OTHER", + } + ] + ) + + after = self._get_user_counts(self.admin_user) + + role = LIBRARY_USER.external_key + self.assertEqual(after[role], before[role] + 1) + + def test_get_roles_user_count_only_counts_scopes_the_caller_can_view(self): + """``user_count`` only counts assignments in scopes the caller can view. + + Expected result: + - An admin sees both new users + - ``regular_5``, library admin of ``lib:Org3:LIB3`` only, sees the user in that library only + """ + regular_5 = User.objects.get(username="regular_5") + role = LIBRARY_USER.external_key + admin_before = self._get_user_counts(self.admin_user)[role] + viewer_before = self._get_user_counts(regular_5)[role] + self._assign_roles_to_users( + assignments=[ + {"subject_name": "regular_6", "role_name": role, "scope_name": "lib:Org3:LIB3"}, + {"subject_name": "regular_7", "role_name": role, "scope_name": "lib:Org9:OTHER"}, + ] + ) + + self.assertEqual(self._get_user_counts(self.admin_user)[role], admin_before + 2) + self.assertEqual(self._get_user_counts(regular_5)[role], viewer_before + 1) + + def test_get_roles_user_count_is_zero_without_assignments_in_scope_types(self): + """A role with no assignments in the requested scope types has a ``user_count`` of 0. + + Expected result: + - ``library_author`` is listed for ``course-v1`` and counts no library assignments there + """ + self._assign_roles_to_users( + assignments=[ + { + "subject_name": "regular_2", + "role_name": LIBRARY_AUTHOR.external_key, + "scope_name": "lib:Org3:LIB3", + } + ] + ) + + response = self.client.get(self.url, {"scope_types": "course-v1"}) + + self.assertEqual(response.data["results"][0]["role"], LIBRARY_AUTHOR.external_key) + self.assertEqual(response.data["results"][0]["user_count"], 0) + + def _get_role_ids_for(self, scope_types: str, query_params: dict) -> list[str]: + """Return the role identifiers of the results for the scope types with the given parameters.""" + response = self.client.get(self.url, {"scope_types": scope_types, "page_size": 100, **query_params}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + return [r["role"] for r in response.data["results"]] + + def _get_role_ids(self, query_params: dict) -> list[str]: + """Return the role identifiers of the results for the library catalog with the given parameters.""" + return self._get_role_ids_for("lib", query_params) + + def test_get_roles_search_filters_by_display_name(self): + """``search`` keeps the roles whose display name contains it, ignoring case. + + Expected result: + - Only ``Library Author`` matches ``AUTHOR``, and ``count`` is the filtered total + - The permissions catalog is not filtered + """ + unfiltered = self.client.get(self.url, {"scope_types": "lib"}) + + response = self.client.get(self.url, {"scope_types": "lib", "search": "AUTHOR"}) + + self.assertEqual([r["role"] for r in response.data["results"]], [LIBRARY_AUTHOR.external_key]) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["permissions"], unfiltered.data["permissions"]) + + def test_get_roles_definition_kind_filters_roles(self): + """``definition_kind`` keeps only the roles of that kind. + + Expected result: + - Every listed role is static, and there are no user-defined roles yet + """ + static = self.client.get(self.url, {"scope_types": "lib", "definition_kind": "static"}) + user_defined = self.client.get(self.url, {"scope_types": "lib", "definition_kind": "user_defined"}) + + self.assertGreater(static.data["count"], 0) + self.assertTrue(all(r["definition_kind"] == "static" for r in static.data["results"])) + self.assertEqual(user_defined.data["count"], 0) + self.assertEqual(user_defined.data["results"], []) + + def test_get_roles_are_sorted_by_display_name_by_default(self): + """Without ``sort_by``, roles are sorted by display name, ascending. + + Expected result: + - ``Library Author`` is listed before ``Library User``, and ``desc`` reverses them + """ + AuthzRoleDefinition.objects.filter(role_id=LIBRARY_AUTHOR.external_key).update(display_name="Zeta") + + ascending = self._get_role_ids({}) + descending = self._get_role_ids({"order": "desc"}) + + self.assertLess(ascending.index(LIBRARY_USER.external_key), ascending.index("library_author")) + self.assertEqual(descending[0], LIBRARY_AUTHOR.external_key) + + def test_get_roles_sort_by_user_count(self): + """``sort_by=user_count`` sorts by the number of users. + + Expected result: + - The role with the most users comes first in ``desc`` and last in ``asc`` + """ + self._assign_roles_to_users( + assignments=[ + {"subject_name": username, "role_name": LIBRARY_AUTHOR.external_key, "scope_name": "lib:Org9:X"} + for username in ( + "regular_6", + "regular_7", + "regular_10", + "admin_2", + "admin_3", + "course_editor", + "library_admin", + ) + ] + ) + + descending = self._get_role_ids({"sort_by": "user_count", "order": "desc"}) + ascending = self._get_role_ids({"sort_by": "user_count", "order": "asc"}) + + self.assertEqual(descending[0], LIBRARY_AUTHOR.external_key) + self.assertEqual(ascending[-1], LIBRARY_AUTHOR.external_key) + + def test_get_roles_sort_by_scope_type(self): + """``sort_by=scope_type`` sorts by the scope types in which each role has grants. + + Expected result: + - ``Library Author``, with grants in ``course-v1`` and ``lib``, sorts before the + ``lib``-only roles in ``asc`` and after them in ``desc`` + """ + ascending = self._get_role_ids_for("course-v1,lib", {"sort_by": "scope_type", "order": "asc"}) + descending = self._get_role_ids_for("course-v1,lib", {"sort_by": "scope_type", "order": "desc"}) + + self.assertEqual(ascending[0], LIBRARY_AUTHOR.external_key) + self.assertEqual(descending[-1], LIBRARY_AUTHOR.external_key) + + def test_get_roles_sort_by_updated_at_breaks_ties_by_role(self): + """Static roles have no ``updated_at``, so ``sort_by=updated_at`` lists them by role identifier. + + Expected result: + - The order is by role identifier, ascending, in both orders + """ + ascending = self._get_role_ids({"sort_by": "updated_at", "order": "asc"}) + descending = self._get_role_ids({"sort_by": "updated_at", "order": "desc"}) + + self.assertEqual(ascending, sorted(ascending)) + self.assertEqual(descending, ascending) + + def test_get_roles_search_and_sort_combine(self): + """Search, filter and sort apply together. + + Expected result: + - Only the static roles matching ``library`` are listed, sorted by display name descending + """ + result = self._get_role_ids({"search": "library", "definition_kind": "static", "order": "desc"}) + + self.assertEqual(result, [LIBRARY_USER.external_key, LIBRARY_AUTHOR.external_key]) + + def test_get_roles_search_ignores_surrounding_spaces(self): + """Regression: ``search`` is matched after the serializer strips it. + + Expected result: + - ``" author "`` matches ``Library Author`` as ``author`` does + """ + self.assertEqual(self._get_role_ids({"search": " author "}), [LIBRARY_AUTHOR.external_key]) + + def test_get_roles_search_without_matches(self): + """A search that matches no role returns an empty page. + + Expected result: + - Returns 200 OK with ``count`` 0 and no results; the catalogs are still returned + """ + response = self.client.get(self.url, {"scope_types": "lib", "search": "no such role"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + self.assertEqual(response.data["results"], []) + self.assertNotEqual(response.data["permissions"], []) + + def test_get_roles_sort_is_applied_before_pagination(self): + """Sorting runs over every role, so pages follow the sorted order. + + Expected result: + - Concatenating one-role pages gives the same order as a single page + """ + all_roles = self._get_role_ids({"order": "desc"}) + + paged = [ + self.client.get(self.url, {"scope_types": "lib", "order": "desc", "page": page, "page_size": 1}).data[ + "results" + ][0]["role"] + for page in range(1, len(all_roles) + 1) + ] + + self.assertEqual(paged, all_roles) + + @data( + {"search": "author"}, + {"definition_kind": "static"}, + {"definition_kind": "user_defined"}, + {"sort_by": "display_name", "order": "asc"}, + {"sort_by": "scope_type", "order": "desc"}, + {"sort_by": "user_count"}, + {"sort_by": "updated_at"}, + # An empty value falls back to the default, as in the other endpoints of this API + {"sort_by": "", "order": "", "definition_kind": ""}, + ) + def test_get_roles_list_control_params_are_accepted(self, query_params: dict): + """The list control parameters accept their supported values. + + Expected result: + - Returns 200 OK status + """ + response = self.client.get(self.url, {"scope_types": "lib", **query_params}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @data( + ({"sort_by": "role"}, "sort_by"), + ({"sort_by": "modified"}, "sort_by"), + ({"order": "ascending"}, "order"), + ({"definition_kind": "custom"}, "definition_kind"), + ) + @unpack + def test_get_roles_list_control_params_are_invalid(self, query_params: dict, field: str): + """Unknown values of the list control parameters are rejected. + + Expected result: + - Returns 400 BAD REQUEST status with an ``invalid_choice`` error on the field + """ + response = self.client.get(self.url, {"scope_types": "lib", **query_params}) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("invalid_choice", [error.code for error in response.data[field]]) + @data( # Unauthenticated (None, "lib", status.HTTP_401_UNAUTHORIZED),