Skip to content

Commit 762b602

Browse files
feat: adding bulk user validation endpoint for admin console
1 parent 2b732c3 commit 762b602

8 files changed

Lines changed: 533 additions & 3 deletions

File tree

CHANGELOG.rst

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
1.6.0 - 2026-04-10
18+
******************
19+
20+
Added
21+
=====
22+
23+
* Add ``users/validate`` endpoint for bulk validation of user identifiers (usernames or emails).
24+
1725
1.5.0 - 2026-04-09
1826
******************
1927

@@ -370,4 +378,4 @@ Added
370378
Added
371379
=====
372380

373-
* Basic repo structure and initial setup.
381+
* Basic repo structure and initial setup.

openedx_authz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import os
66

7-
__version__ = "1.5.0"
7+
__version__ = "1.6.0"
88

99
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

openedx_authz/api/users.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
(e.g., 'user^john_doe').
1010
"""
1111

12+
import logging
13+
14+
from django.contrib.auth import get_user_model
15+
1216
from openedx_authz.api.data import (
1317
ActionData,
1418
PermissionData,
@@ -35,6 +39,7 @@
3539
unassign_subject_from_all_roles,
3640
)
3741
from openedx_authz.api.utils import filter_user_assignments, get_user_assignment_map
42+
from openedx_authz.utils import get_user_by_username_or_email
3843

3944
__all__ = [
4045
"assign_role_to_user_in_scope",
@@ -51,8 +56,11 @@
5156
"get_scopes_for_user_and_permission",
5257
"get_users_for_role_in_scope",
5358
"unassign_all_roles_from_user",
59+
"validate_users",
5460
]
5561

62+
logger = logging.getLogger(__name__)
63+
5664

5765
def assign_role_to_user_in_scope(user_external_key: str, role_external_key: str, scope_external_key: str) -> bool:
5866
"""Assign a role to a user in a specific scope.
@@ -339,3 +347,32 @@ def unassign_all_roles_from_user(user_external_key: str) -> bool:
339347
bool: True if any roles were removed, False otherwise.
340348
"""
341349
return unassign_subject_from_all_roles(UserData(external_key=user_external_key))
350+
351+
352+
def validate_users(user_identifiers: list[str]) -> tuple[list[str], list[str]]:
353+
"""Validate a list of user identifiers.
354+
355+
Args:
356+
user_identifiers (list[str]): List of usernames or emails to validate
357+
358+
Returns:
359+
tuple: (valid_users, invalid_users) lists
360+
"""
361+
User = get_user_model()
362+
valid_users = []
363+
invalid_users = []
364+
365+
for user_identifier in user_identifiers:
366+
try:
367+
user = get_user_by_username_or_email(user_identifier)
368+
if user.is_active:
369+
valid_users.append(user_identifier)
370+
else:
371+
invalid_users.append(user_identifier)
372+
except User.DoesNotExist:
373+
invalid_users.append(user_identifier)
374+
except Exception as e:
375+
logger.error(f"Unexpected error validating user {user_identifier}: {e}")
376+
raise # Re-raise to let the caller handle it
377+
378+
return valid_users, invalid_users

openedx_authz/rest_api/v1/serializers.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,36 @@ def get_email(self, obj: UserAssignments) -> str:
259259
def get_assignation_count(self, obj: UserAssignments) -> int:
260260
"""Get the assignation count for the given role assignment."""
261261
return len(obj.assignments)
262+
263+
264+
class UserValidationAPIViewSerializer(serializers.Serializer): # pylint: disable=abstract-method
265+
"""Serializer for validating user existence."""
266+
267+
users = serializers.ListField(
268+
child=serializers.CharField(max_length=255), allow_empty=False, help_text="List of user identifiers to validate"
269+
)
270+
271+
def validate_users(self, value) -> list[str]:
272+
"""Eliminate duplicates preserving order"""
273+
return list(dict.fromkeys(value))
274+
275+
276+
class UserValidationSummarySerializer(serializers.Serializer): # pylint: disable=abstract-method
277+
"""Serializer for user validation summary statistics."""
278+
279+
total = serializers.IntegerField(help_text="Total number of users validated")
280+
valid_count = serializers.IntegerField(help_text="Number of valid users found")
281+
invalid_count = serializers.IntegerField(help_text="Number of invalid users")
282+
283+
284+
class UserValidationAPIViewResponseSerializer(serializers.Serializer): # pylint: disable=abstract-method
285+
"""Serializer for user validation response."""
286+
287+
valid_users = serializers.ListField(
288+
child=serializers.CharField(max_length=255), help_text="List of user identifiers that were found to be valid"
289+
)
290+
invalid_users = serializers.ListField(
291+
child=serializers.CharField(max_length=255),
292+
help_text="List of user identifiers that were not found or are invalid",
293+
)
294+
summary = UserValidationSummarySerializer(help_text="Summary statistics for the validation operation")

openedx_authz/rest_api/v1/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@
1414
path("roles/users/", views.RoleUserAPIView.as_view(), name="role-user-list"),
1515
path("orgs/", views.AdminConsoleOrgsAPIView.as_view(), name="orgs-list"),
1616
path("users/", views.TeamMembersAPIView.as_view(), name="user-list"),
17+
path("users/validate/", views.UserValidationAPIView.as_view(), name="user-validation"),
1718
]

openedx_authz/rest_api/v1/views.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
RemoveUsersFromRoleWithScopeSerializer,
4343
TeamMemberSerializer,
4444
UserRoleAssignmentSerializer,
45+
UserValidationAPIViewResponseSerializer,
46+
UserValidationAPIViewSerializer,
4547
)
4648
from openedx_authz.utils import get_user_by_username_or_email
4749

@@ -586,3 +588,86 @@ def get(self, request: HttpRequest) -> Response:
586588
paginator = self.pagination_class()
587589
paginated_response_data = paginator.paginate_queryset(team_members, request)
588590
return paginator.get_paginated_response(paginated_response_data)
591+
592+
593+
@view_auth_classes()
594+
class UserValidationAPIView(APIView):
595+
"""API view for validating that provided user identifiers correspond to existing users.
596+
597+
This view allows clients to verify that a list of user identifiers (usernames or emails)
598+
correspond to valid users in the system. It is designed to support bulk validation of multiple
599+
user identifiers in a single request, providing a convenient way to check the validity of users before
600+
performing operations such as role assignments.
601+
602+
**Endpoints**
603+
- POST: Validate that the provided list of usernames or emails correspond to existing users
604+
605+
**Request Format (POST)**
606+
- users: List of user identifiers (username or email)
607+
608+
**Response Format (POST)**
609+
610+
Returns HTTP 200 OK with::
611+
612+
{
613+
"valid_users": ["john_doe", "[email protected]"],
614+
"invalid_users": ["nonexistent_user"],
615+
"summary": {
616+
"total": 3,
617+
"valid_count": 2,
618+
"invalid_count": 1
619+
}
620+
}
621+
622+
**Authentication and Permissions**
623+
624+
- Requires authenticated user.
625+
- Requires ``manage_library_team`` or ``manage_course_team`` permission in any scope.
626+
627+
**Example Request**
628+
629+
POST /api/authz/v1/users/validate/ ::
630+
631+
{
632+
"users": ["john_doe", "[email protected]", "nonexistent_user"]
633+
}
634+
"""
635+
636+
permission_classes = [AnyScopePermission]
637+
638+
@apidocs.schema(
639+
body=UserValidationAPIViewSerializer,
640+
responses={
641+
status.HTTP_200_OK: UserValidationAPIViewResponseSerializer,
642+
status.HTTP_400_BAD_REQUEST: "The request data is invalid",
643+
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated",
644+
status.HTTP_403_FORBIDDEN: "The user does not have the required permissions",
645+
status.HTTP_500_INTERNAL_SERVER_ERROR: "An unexpected error occurred while validating users",
646+
},
647+
)
648+
@authz_permissions([permissions.MANAGE_LIBRARY_TEAM.identifier, permissions.COURSES_MANAGE_COURSE_TEAM.identifier])
649+
def post(self, request: HttpRequest) -> Response:
650+
"""Validates the provided usernames or emails correspond to existing users."""
651+
request_serializer = UserValidationAPIViewSerializer(data=request.data)
652+
request_serializer.is_valid(raise_exception=True)
653+
serialized_request_users = request_serializer.validated_data["users"]
654+
try:
655+
valid_users, invalid_users = api.validate_users(serialized_request_users)
656+
except Exception as e: # pylint: disable=broad-exception-caught
657+
logger.error(f"Error validating users: {e}")
658+
return Response(
659+
data={"message": "An error occurred while validating users"},
660+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
661+
)
662+
663+
response_data = {
664+
"valid_users": valid_users,
665+
"invalid_users": invalid_users,
666+
"summary": {
667+
"total": len(serialized_request_users),
668+
"valid_count": len(valid_users),
669+
"invalid_count": len(invalid_users),
670+
},
671+
}
672+
response_serializer = UserValidationAPIViewResponseSerializer(response_data)
673+
return Response(response_serializer.data, status=status.HTTP_200_OK)

openedx_authz/tests/api/test_users.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""Test suite for user-role assignment API functions."""
22

3+
from unittest.mock import patch
4+
35
from ddt import data, ddt, unpack
6+
from django.contrib.auth import get_user_model
47

58
from openedx_authz.api.data import ContentLibraryData, RoleAssignmentData, RoleData, UserData
69
from openedx_authz.api.users import (
@@ -14,6 +17,7 @@
1417
is_user_allowed,
1518
unassign_all_roles_from_user,
1619
unassign_role_from_user,
20+
validate_users,
1721
)
1822
from openedx_authz.constants import permissions, roles
1923
from openedx_authz.constants.roles import LIBRARY_ADMIN_PERMISSIONS, LIBRARY_AUTHOR_PERMISSIONS
@@ -514,3 +518,66 @@ def test_is_user_allowed(self, username, action, scope_name, expected_result):
514518
scope_external_key=scope_name,
515519
)
516520
self.assertEqual(result, expected_result)
521+
522+
523+
@ddt
524+
class TestValidateUsersAPI(UserAssignmentsSetupMixin):
525+
"""Test suite for validate_users API function - focused on business logic."""
526+
527+
def test_validate_users_empty_list(self):
528+
"""Test validate_users with empty input list."""
529+
valid_users, invalid_users = validate_users([])
530+
531+
self.assertEqual(valid_users, [])
532+
self.assertEqual(invalid_users, [])
533+
534+
def test_validate_users_inactive_user_edge_case(self):
535+
"""Test that inactive users are correctly identified as invalid."""
536+
User = get_user_model()
537+
538+
# Create an inactive user for this test
539+
inactive_user = User.objects.create_user(
540+
username="inactive_api_test", email="[email protected]", is_active=False
541+
)
542+
543+
valid_users, invalid_users = validate_users([inactive_user.username])
544+
545+
# Cleanup
546+
inactive_user.delete()
547+
548+
self.assertEqual(valid_users, [])
549+
self.assertEqual(invalid_users, [inactive_user.username])
550+
551+
@patch("openedx_authz.api.users.get_user_by_username_or_email")
552+
def test_validate_users_unexpected_exception_propagation(self, mock_get_user):
553+
"""Test that unexpected exceptions from get_user_by_username_or_email are re-raised."""
554+
# Simulate an unexpected database error
555+
mock_get_user.side_effect = Exception("Database connection lost")
556+
557+
with self.assertRaises(Exception) as cm:
558+
validate_users(["any_user"])
559+
560+
self.assertEqual(str(cm.exception), "Database connection lost")
561+
mock_get_user.assert_called_once_with("any_user")
562+
563+
@patch("openedx_authz.api.users.get_user_by_username_or_email")
564+
def test_validate_users_user_does_not_exist_handling(self, mock_get_user):
565+
"""Test handling of User.DoesNotExist exception."""
566+
User = get_user_model()
567+
mock_get_user.side_effect = User.DoesNotExist("User not found")
568+
569+
valid_users, invalid_users = validate_users(["nonexistent_user"])
570+
571+
self.assertEqual(valid_users, [])
572+
self.assertEqual(invalid_users, ["nonexistent_user"])
573+
574+
@patch("openedx_authz.api.users.logger")
575+
def test_validate_users_logs_unexpected_exceptions(self, mock_logger):
576+
"""Test that unexpected exceptions are properly logged before re-raising."""
577+
with patch("openedx_authz.api.users.get_user_by_username_or_email") as mock_get_user:
578+
mock_get_user.side_effect = Exception("Connection timeout")
579+
with self.assertRaises(Exception):
580+
validate_users(["test_user"])
581+
mock_logger.error.assert_called_once()
582+
log_call_args = mock_logger.error.call_args[0][0]
583+
self.assertIn("Unexpected error validating user test_user", log_call_args)

0 commit comments

Comments
 (0)