Skip to content

Commit 0cfab17

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

7 files changed

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

0 commit comments

Comments
 (0)