Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bb54b12
[feature] Added REST API list, detail, and delete endpoints for RADIU…
BHARATH0153 Aug 20, 2026
39929b9
[fix] Fixed RST formatting in batch API docs #771
BHARATH0153 Aug 20, 2026
d8a1a37
[fix] Addressed review feedback on batch REST API endpoints #771
BHARATH0153 Aug 20, 2026
b6cf149
Update openwisp_radius/tests/test_api/test_api_batch.py
BHARATH0153 Aug 20, 2026
9a6aa10
[change] Addressed review feedback on batch REST API endpoints #771
BHARATH0153 Aug 20, 2026
cc9525f
[fix] File name
BHARATH0153 Aug 20, 2026
10c44d4
[change] Added query optimization and sample-app tests for batch endp…
BHARATH0153 Aug 20, 2026
7c78a99
[fix] Renamed test.batch.py to test_batch.py #771
BHARATH0153 Aug 20, 2026
63da3bc
[fix] Fixed Black/flake8 formatting in batch test #771
BHARATH0153 Aug 20, 2026
5289992
[fix] Fixed race condition in batch delete atomicity #771
BHARATH0153 Aug 20, 2026
fe04cdc
Update openwisp_radius/api/urls.py
BHARATH0153 Aug 21, 2026
4c6fc28
[change] Addressed nemesifier review feedback #771
BHARATH0153 Aug 21, 2026
cb4f05e
[fix] Fixed URL name mismatch for batch detail endpoint #771
BHARATH0153 Aug 21, 2026
aefe230
[change] Renamed URL name to radius_batch_detail per convention #771
BHARATH0153 Aug 21, 2026
5b99f8d
Merge branch 'master' into issues/771-batch-rest-api-list-detail-delete
BHARATH0153 Aug 24, 2026
8b961f4
Merge branch 'master' into issues/771-batch-rest-api-list-detail-delete
nemesifier Aug 25, 2026
fdabb84
Update openwisp_radius/api/urls.py
BHARATH0153 Aug 25, 2026
6771df0
[change] Simplify perform_destroy and rewrite batch test helpers
BHARATH0153 Aug 25, 2026
d6f4943
[change] Remove _create_prefix_batch wrapper, use _create_radius_batc…
BHARATH0153 Aug 25, 2026
eefb95e
Merge branch 'master' into issues/771-batch-rest-api-list-detail-delete
BHARATH0153 Aug 25, 2026
a131805
[change] Use _superuser_login for admin tests, simplify _get_auth_header
BHARATH0153 Aug 26, 2026
d61c68e
[fix] Fix assertNumQueries for session auth in batch list test
BHARATH0153 Aug 26, 2026
6a01468
Merge branch 'master' into issues/771-batch-rest-api-list-detail-delete
BHARATH0153 Aug 26, 2026
2caf25f
Merge branch 'master' into issues/771-batch-rest-api-list-detail-delete
BHARATH0153 Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 77 additions & 3 deletions docs/user/rest-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -869,12 +869,49 @@ This API endpoint allows to use the features described in

/api/v1/radius/batch/

GET
^^^

Returns a list of batch user creation operations for the organizations
managed by the requesting user. Results are paginated and can be filtered.

.. code-block:: text

/api/v1/radius/batch?search=<batch_name>
/api/v1/radius/batch?organization=<org_id>
/api/v1/radius/batch?strategy=prefix

Filters
"""""""

================= ===============================
Filter Parameter Description
================= ===============================
search Search batches by name
organization Filter by organization id
organization_slug Filter by organization slug
strategy Filter by strategy (prefix/csv)
================= ===============================

Pagination
""""""""""

Pagination is provided using page number pagination, the default page size
is 20, which can be overridden using the ``page_size`` parameter, up to
:ref:`OPENWISP_API_MAX_PAGE_SIZE <openwisp_api_max_page_size>` (100 by
default).

.. note::

This API endpoint allows to use the features described in
:doc:`importing_users` and :doc:`generating_users`.
The list response does not include ``user_credentials`` to avoid
repeatedly exposing plaintext credentials. Use the batch creation
response or the protected PDF download endpoint for credentials.

Responds only to **POST**, used to save a ``RadiusBatch`` instance.
POST
^^^^

Creates a batch of users using a csv file or generates users with a given
prefix.

It is possible to generate the users of the ``RadiusBatch`` with two
different strategies: csv or prefix.
Expand Down Expand Up @@ -919,6 +956,43 @@ group by organization before sending its UUID in the ``group`` parameter.
The ``group`` and ``notes`` parameters are optional. When ``group`` is
omitted, users retain the standard default-group behavior.

.. note::

The synchronous ``201 Created`` response for prefix-generated batches
includes ``user_credentials``. The asynchronous ``202 Accepted``
response does not include credentials; use the ``pdf_link`` after
completion.

Batch Detail
++++++++++++

.. code-block:: text

/api/v1/radius/batch/<uuid>/

GET
^^^

Returns a single batch user creation operation by its UUID. The response
does not include ``user_credentials``.

For completed prefix batches, the response includes a ``pdf_link`` field
pointing to the protected PDF download endpoint. For CSV batches with an
uploaded file, the response includes a ``csv_link`` field pointing to the
protected CSV download endpoint.

DELETE
^^^^^^

Deletes a batch user creation operation and its associated users. Returns
``204 No Content`` on success.

.. note::

Deletion is rejected while the batch ``status`` is ``processing``. The
API returns a ``409 Conflict`` response with a clear error message in
this case. Pending, completed, and failed batches can be deleted.

Batch CSV Download
++++++++++++++++++

Expand Down
23 changes: 20 additions & 3 deletions openwisp_radius/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,11 +506,28 @@ def get_actions(self, request):

@admin.action(description=_("Delete selected batches"), permissions=["delete"])
def delete_selected_batches(self, request, queryset):
skipped = 0
deleted = 0
for obj in queryset:
if obj.status == RadiusBatch.PROCESSING:
skipped += 1
continue
obj.delete()
self.message_user(
request, "Successfully deleted selected batches.", level=messages.SUCCESS
)
deleted += 1
if skipped:
self.message_user(
request,
_(
"Skipped {count} batch(es) that are currently being processed."
).format(count=skipped),
level=messages.WARNING,
)
Comment thread
BHARATH0153 marked this conversation as resolved.
Comment thread
BHARATH0153 marked this conversation as resolved.
if deleted:
self.message_user(
request,
_("Successfully deleted {count} batch(es).").format(count=deleted),
level=messages.SUCCESS,
)

def get_readonly_fields(self, request, obj=None):
readonly_fields = super(RadiusBatchAdmin, self).get_readonly_fields(
Expand Down
60 changes: 60 additions & 0 deletions openwisp_radius/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,66 @@ class Meta:
read_only_fields = ("status", "user_credentials", "created", "modified")


class BatchUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = (
"id",
"username",
"email",
"first_name",
"last_name",
)
read_only_fields = fields


class RadiusBatchReadSerializer(serializers.ModelSerializer):
organization = serializers.PrimaryKeyRelatedField(read_only=True)
users = BatchUserSerializer(many=True, read_only=True)
pdf_link = serializers.SerializerMethodField(required=False, read_only=True)
csv_link = serializers.SerializerMethodField(required=False, read_only=True)
status = serializers.CharField(read_only=True)

def get_pdf_link(self, obj):
if obj.strategy == "prefix" and obj.status == RadiusBatch.COMPLETED:
request = self.context.get("request")
return request.build_absolute_uri(
reverse(
"radius:download_rad_batch_pdf",
args=[obj.organization.slug, obj.pk],
)
)
return None

def get_csv_link(self, obj):
if obj.csvfile:
request = self.context.get("request")
csv_url = reverse(
"radius:radius_organization_batch_csv_read",
args=[obj.organization.slug, obj.pk],
)
return request.build_absolute_uri(csv_url)
return None

class Meta:
model = RadiusBatch
fields = (
"id",
"organization",
"name",
"strategy",
"status",
"expiration_date",
"prefix",
"users",
"pdf_link",
"csv_link",
"created",
"modified",
)
read_only_fields = fields


class RegisterSerializer(
ErrorDictMixin,
AllowedMobilePrefixMixin,
Expand Down
5 changes: 5 additions & 0 deletions openwisp_radius/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ def get_view(name):
name="update_registered_user_registration_method",
),
path("radius/batch/", get_view("batch"), name="batch"),
path(
"radius/batch/<uuid:pk>/",
get_view("batch_detail"),
name="radius_batch_detail",
),
Comment thread
BHARATH0153 marked this conversation as resolved.
path(
"radius/organization/<slug:slug>/batch/<uuid:pk>/pdf/",
get_view("download_rad_batch_pdf"),
Expand Down
107 changes: 96 additions & 11 deletions openwisp_radius/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@
from rest_framework.authentication import SessionAuthentication
from rest_framework.authtoken.models import Token as UserToken
from rest_framework.authtoken.views import ObtainAuthToken as BaseObtainAuthToken
from rest_framework.exceptions import NotFound, PermissionDenied
from rest_framework.exceptions import APIException, NotFound, PermissionDenied
from rest_framework.filters import SearchFilter
from rest_framework.generics import (
CreateAPIView,
GenericAPIView,
ListAPIView,
ListCreateAPIView,
RetrieveAPIView,
RetrieveDestroyAPIView,
RetrieveUpdateDestroyAPIView,
get_object_or_404,
)
Expand Down Expand Up @@ -78,6 +79,7 @@
AuthTokenSerializer,
ChangePhoneNumberSerializer,
RadiusAccountingSerializer,
RadiusBatchReadSerializer,
RadiusBatchSerializer,
RadiusGroupSerializer,
RadiusUserGroupSerializer,
Expand Down Expand Up @@ -120,20 +122,53 @@ class ThrottledAPIMixin(object):
throttle_scope = "others"


class BatchView(ThrottledAPIMixin, CreateAPIView):
authentication_classes = (BearerAuthentication, SessionAuthentication)
permission_classes = (IsAdminUser, DjangoModelPermissions)
queryset = RadiusBatch.objects.all()
serializer_class = RadiusBatchSerializer
class RadiusBatchFilter(OrganizationManagedFilter, filters.FilterSet):
class Meta(OrganizationManagedFilter.Meta):
model = RadiusBatch
fields = [*OrganizationManagedFilter.Meta.fields, "strategy"]

def post(self, request, *args, **kwargs):
"""

@method_decorator(
name="get",
decorator=swagger_auto_schema(
operation_description="""
Returns a list of batch user creation operations for the
organizations managed by the user.
""",
),
)
@method_decorator(
name="post",
decorator=swagger_auto_schema(
operation_description="""
**Requires the user auth token (Bearer Token).**
Allows organization administrators to create
a batch of users using a csv file or generate users
with a given prefix.
"""
serializer = self.get_serializer(data=request.data)
""",
request_body=RadiusBatchSerializer,
responses={201: RadiusBatchSerializer, 202: RadiusBatchSerializer},
),
)
class BatchView(ThrottledAPIMixin, FilterByOrganizationManaged, ListCreateAPIView):
authentication_classes = (BearerAuthentication, SessionAuthentication)
permission_classes = (IsAdminUser, DjangoModelPermissions)
queryset = (
RadiusBatch.objects.select_related("organization")
.prefetch_related("users")
.order_by("-created")
)
serializer_class = RadiusBatchReadSerializer
filterset_class = RadiusBatchFilter
filter_backends = [DjangoFilterBackend, SearchFilter]
search_fields = ["name"]
pagination_class = OpenWispPagination
pagination_page_size = 20

def post(self, request, *args, **kwargs):
serializer = RadiusBatchSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
valid_data = serializer.validated_data.copy()
num_of_users = valid_data.get("number_of_users", 0)
Expand All @@ -142,7 +177,9 @@ def post(self, request, *args, **kwargs):
batch = serializer.save(organization=organization)
is_async = batch.schedule_processing(number_of_users=num_of_users)
batch.refresh_from_db()
response_serializer = self.get_serializer(batch)
response_serializer = RadiusBatchSerializer(
batch, context={"request": request}
)
status_code = (
status.HTTP_202_ACCEPTED if is_async else status.HTTP_201_CREATED
)
Expand Down Expand Up @@ -196,6 +233,54 @@ def get(self, request, *args, **kwargs):
download_rad_batch_pdf = DownloadRadiusBatchPdfView.as_view()


class Conflict(APIException):
status_code = status.HTTP_409_CONFLICT
default_detail = _("Conflict.")
default_code = "conflict"


@method_decorator(
name="get",
decorator=swagger_auto_schema(
operation_description="""
Returns a batch user creation operation by its UUID.
""",
),
)
@method_decorator(
name="delete",
decorator=swagger_auto_schema(
operation_description="""
Deletes a batch user creation operation and its associated users.
Cannot delete a batch while it is being processed.
""",
responses={204: "No Content", 409: "Conflict"},
),
)
class BatchDetailView(
ProtectedAPIMixin, FilterByOrganizationManaged, RetrieveDestroyAPIView
):
authentication_classes = (BearerAuthentication, SessionAuthentication)
permission_classes = (IsAdminUser, DjangoModelPermissions)
queryset = RadiusBatch.objects.select_related("organization").prefetch_related(
"users"
)
serializer_class = RadiusBatchReadSerializer

def perform_destroy(self, instance):
if instance.status == RadiusBatch.PROCESSING:
raise Conflict(
_(
"The radius batch object is currently being processed"
" and cannot be deleted."
)
)
instance.delete()


batch_detail = BatchDetailView.as_view()


class UserDetailsUpdaterMixin(object):
def update_user_details(self, user):
language = get_language_from_request(self.request)
Expand Down
Loading
Loading