Skip to content

Commit 34d9a6c

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

7 files changed

Lines changed: 408 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 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/rest_api/v1/fields.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,11 @@ def to_internal_value(self, data):
3737
def to_representation(self, value):
3838
"""Convert string to lowercase"""
3939
return value.strip().lower()
40+
41+
42+
class UserValidationSummarySerializer(serializers.Serializer): # pylint: disable=abstract-method
43+
"""Serializer for user validation summary statistics."""
44+
45+
total = serializers.IntegerField(help_text="Total number of users validated")
46+
valid_count = serializers.IntegerField(help_text="Number of valid users found")
47+
invalid_count = serializers.IntegerField(help_text="Number of invalid users")

openedx_authz/rest_api/v1/serializers.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
CaseSensitiveCommaSeparatedListField,
1212
CommaSeparatedListField,
1313
LowercaseCharField,
14+
UserValidationSummarySerializer,
1415
)
1516

1617
User = get_user_model()
@@ -259,3 +260,26 @@ def get_email(self, obj: UserAssignments) -> str:
259260
def get_assignation_count(self, obj: UserAssignments) -> int:
260261
"""Get the assignation count for the given role assignment."""
261262
return len(obj.assignments)
263+
class UserValidationAPIViewSerializer(serializers.Serializer): # pylint: disable=abstract-method
264+
"""Serializer for validating user existence."""
265+
266+
users = serializers.ListField(
267+
child=serializers.CharField(max_length=255), allow_empty=False, help_text="List of user identifiers to validate"
268+
)
269+
270+
def validate_users(self, value) -> list[str]:
271+
"""Eliminate duplicates preserving order"""
272+
return list(dict.fromkeys(value))
273+
274+
275+
class UserValidationAPIViewResponseSerializer(serializers.Serializer): # pylint: disable=abstract-method
276+
"""Serializer for user validation response."""
277+
278+
valid_users = serializers.ListField(
279+
child=serializers.CharField(max_length=255), help_text="List of user identifiers that were found to be valid"
280+
)
281+
invalid_users = serializers.ListField(
282+
child=serializers.CharField(max_length=255),
283+
help_text="List of user identifiers that were not found or are invalid",
284+
)
285+
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: 86 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,87 @@ 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+
},
646+
)
647+
@authz_permissions([permissions.MANAGE_LIBRARY_TEAM.identifier, permissions.COURSES_MANAGE_COURSE_TEAM.identifier])
648+
def post(self, request: HttpRequest) -> Response:
649+
"""Validates the provided usernames or emails correspond to existing users."""
650+
651+
request_serializer = UserValidationAPIViewSerializer(data=request.data)
652+
request_serializer.is_valid(raise_exception=True)
653+
serialized_request_data = request_serializer.validated_data
654+
valid_users = []
655+
invalid_users = []
656+
for user_identifier in serialized_request_data["users"]:
657+
try:
658+
_user = get_user_by_username_or_email(user_identifier)
659+
valid_users.append(user_identifier)
660+
except User.DoesNotExist:
661+
invalid_users.append(user_identifier)
662+
663+
response_data = {
664+
"valid_users": valid_users,
665+
"invalid_users": invalid_users,
666+
"summary": {
667+
"total": len(serialized_request_data["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)
674+

0 commit comments

Comments
 (0)